Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
||||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||||
|
| 海外 PDA 拣货员与实际收货仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | `WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId`;候选来自 `modules/warehouses/` 的实际海外仓,服务端约束在 `iam/`,Claim 与实时校验联查 `common-security/`、`lastleg-service/security/` |
|
||||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
|
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
|
||||||
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
|
|
||||||
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||||
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
||||||
|
- 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
||||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||||
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||||
@@ -19,10 +21,12 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
|
|||||||
]))
|
]))
|
||||||
|
|
||||||
const stubs = {
|
const stubs = {
|
||||||
'el-menu': {
|
'el-menu': defineComponent({
|
||||||
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
|
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
|
||||||
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)"><slot /></nav>',
|
emits: ['select'],
|
||||||
},
|
methods: { updateActiveIndex() {} },
|
||||||
|
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)" @click="$emit(\'select\', \'/warehouses\')"><slot /></nav>',
|
||||||
|
}),
|
||||||
'el-sub-menu': {
|
'el-sub-menu': {
|
||||||
props: ['index'],
|
props: ['index'],
|
||||||
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
||||||
@@ -34,10 +38,20 @@ const stubs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AdminSidebarMenu', () => {
|
describe('AdminSidebarMenu', () => {
|
||||||
|
function routerPlugin() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<p />' } },
|
||||||
|
{ path: '/warehouses', component: { template: '<p />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the visible phase-one modules and forwards the active route', () => {
|
it('renders the visible phase-one modules and forwards the active route', () => {
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
|
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
||||||
@@ -62,7 +76,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
it('forwards desktop collapse state to Element Plus', () => {
|
it('forwards desktop collapse state to Element Plus', () => {
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items: visibleNavigation, activePath: '/', collapsed: true },
|
props: { items: visibleNavigation, activePath: '/', collapsed: true },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||||
@@ -72,7 +86,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items, activePath: '/', collapsed: false },
|
props: { items, activePath: '/', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('菜单权限')
|
expect(wrapper.text()).toContain('菜单权限')
|
||||||
@@ -100,7 +114,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
}]
|
}]
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items, activePath: '/tracking', collapsed: false },
|
props: { items, activePath: '/tracking', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
||||||
@@ -110,7 +124,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
it('recreates the menu when asynchronously loaded groups change', async () => {
|
it('recreates the menu when asynchronously loaded groups change', async () => {
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
|
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
const initialMenu = wrapper.get('nav').element
|
const initialMenu = wrapper.get('nav').element
|
||||||
|
|
||||||
@@ -119,4 +133,17 @@ describe('AdminSidebarMenu', () => {
|
|||||||
expect(wrapper.get('nav').element).not.toBe(initialMenu)
|
expect(wrapper.get('nav').element).not.toBe(initialMenu)
|
||||||
expect(wrapper.text()).toContain('系统管理')
|
expect(wrapper.text()).toContain('系统管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('navigates explicitly when a leaf menu item is selected', async () => {
|
||||||
|
const router = routerPlugin()
|
||||||
|
await router.push('/')
|
||||||
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
|
props: { items: visibleNavigation, activePath: '/', collapsed: false },
|
||||||
|
global: { stubs, plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('nav').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useRouter, type NavigationFailure } from 'vue-router'
|
||||||
|
import type { MenuInstance } from 'element-plus'
|
||||||
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||||
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
||||||
|
|
||||||
@@ -8,6 +10,8 @@ const props = defineProps<{
|
|||||||
activePath: string
|
activePath: string
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const router = useRouter()
|
||||||
|
const menuRef = ref<MenuInstance>()
|
||||||
|
|
||||||
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||||
return nodes.flatMap((node) => node.children?.length
|
return nodes.flatMap((node) => node.children?.length
|
||||||
@@ -18,16 +22,36 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
|||||||
const groupKeys = computed(() => getGroupKeys(props.items))
|
const groupKeys = computed(() => getGroupKeys(props.items))
|
||||||
const defaultOpeneds: string[] = []
|
const defaultOpeneds: string[] = []
|
||||||
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
|
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
|
||||||
|
|
||||||
|
async function restoreActivePath() {
|
||||||
|
await nextTick()
|
||||||
|
menuRef.value?.updateActiveIndex(props.activePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(path: string) {
|
||||||
|
if (!path.startsWith('/') || path === props.activePath) return
|
||||||
|
let failure: NavigationFailure | void
|
||||||
|
try {
|
||||||
|
failure = await router.push(path)
|
||||||
|
} catch {
|
||||||
|
await restoreActivePath()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (failure) await restoreActivePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.activePath, restoreActivePath)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-menu
|
<el-menu
|
||||||
|
ref="menuRef"
|
||||||
:key="menuInstanceKey"
|
:key="menuInstanceKey"
|
||||||
class="side-nav-menu"
|
class="side-nav-menu"
|
||||||
:default-active="activePath"
|
:default-active="activePath"
|
||||||
:collapse="collapsed"
|
:collapse="collapsed"
|
||||||
:default-openeds="defaultOpeneds"
|
:default-openeds="defaultOpeneds"
|
||||||
router
|
@select="navigate"
|
||||||
>
|
>
|
||||||
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
|
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
|
||||||
</el-menu>
|
</el-menu>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
clearNavigationRecoveryAttempt,
|
||||||
|
isStaleRouteAssetError,
|
||||||
|
recoverNavigationError,
|
||||||
|
type NavigationRecoveryDependencies,
|
||||||
|
} from './navigationRecovery'
|
||||||
|
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
||||||
|
return {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
now: () => 1_000,
|
||||||
|
hasUnsavedWork: () => false,
|
||||||
|
confirmDiscard: vi.fn(async () => true),
|
||||||
|
discardUnsavedWork: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} satisfies NavigationRecoveryDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('navigationRecovery', () => {
|
||||||
|
it.each([
|
||||||
|
'Failed to fetch dynamically imported module: /assets/page.js',
|
||||||
|
'Importing a module script failed',
|
||||||
|
'ChunkLoadError: Loading chunk 42 failed',
|
||||||
|
'Loading CSS chunk page failed',
|
||||||
|
])('recognizes stale deployed route assets: %s', (message) => {
|
||||||
|
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
await expect(recoverNavigationError(
|
||||||
|
new TypeError('Failed to fetch dynamically imported module'),
|
||||||
|
'/overseas-warehouse/inventory',
|
||||||
|
deps,
|
||||||
|
)).resolves.toBe(true)
|
||||||
|
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
|
||||||
|
expect(deps.showError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks before discarding dirty work and stays put when the user cancels', async () => {
|
||||||
|
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
|
||||||
|
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
|
||||||
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
const deps = dependencies({ storage })
|
||||||
|
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
||||||
|
await recoverNavigationError(error, '/system/users', deps)
|
||||||
|
await recoverNavigationError(error, '/system/users', deps)
|
||||||
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
||||||
|
clearNavigationRecoveryAttempt(storage)
|
||||||
|
await recoverNavigationError(error, '/system/users', deps)
|
||||||
|
expect(deps.navigate).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports ordinary router failures without reloading the application', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
|
||||||
|
const RECOVERY_WINDOW_MS = 30_000
|
||||||
|
|
||||||
|
const staleAssetErrorPatterns = [
|
||||||
|
/failed to fetch dynamically imported module/i,
|
||||||
|
/error loading dynamically imported module/i,
|
||||||
|
/importing a module script failed/i,
|
||||||
|
/chunkloaderror/i,
|
||||||
|
/loading (css )?chunk [\w-]+ failed/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface NavigationRecoveryDependencies {
|
||||||
|
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||||
|
now: () => number
|
||||||
|
hasUnsavedWork: () => boolean
|
||||||
|
confirmDiscard: () => Promise<boolean>
|
||||||
|
discardUnsavedWork: () => void
|
||||||
|
navigate: (target: string) => void
|
||||||
|
showError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryAttempt {
|
||||||
|
target: string
|
||||||
|
attemptedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return typeof error === 'string' ? error : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStaleRouteAssetError(error: unknown) {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
|
||||||
|
return Boolean(parsed
|
||||||
|
&& parsed.target === target
|
||||||
|
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverNavigationError(
|
||||||
|
error: unknown,
|
||||||
|
target: string,
|
||||||
|
dependencies: NavigationRecoveryDependencies,
|
||||||
|
) {
|
||||||
|
if (!isStaleRouteAssetError(error)) {
|
||||||
|
dependencies.showError('页面切换失败,请重试')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (recentAttempt(dependencies, target)) {
|
||||||
|
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
|
||||||
|
|
||||||
|
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||||
|
target,
|
||||||
|
attemptedAt: dependencies.now(),
|
||||||
|
} satisfies RecoveryAttempt))
|
||||||
|
dependencies.discardUnsavedWork()
|
||||||
|
dependencies.navigate(target)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
|
||||||
|
storage.removeItem(RECOVERY_STORAGE_KEY)
|
||||||
|
}
|
||||||
+28
-1
@@ -1,11 +1,12 @@
|
|||||||
import { computed, createApp } from 'vue'
|
import { computed, createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import 'element-plus/es/components/message/style/css'
|
import 'element-plus/es/components/message/style/css'
|
||||||
import 'element-plus/es/components/message-box/style/css'
|
import 'element-plus/es/components/message-box/style/css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from '@/app/router'
|
import router from '@/app/router'
|
||||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||||
|
import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
@@ -36,6 +37,32 @@ registerAuthSessionExpiredHandler(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.onError((error, to) => {
|
||||||
|
void recoverNavigationError(error, to.fullPath, {
|
||||||
|
storage: sessionStorage,
|
||||||
|
now: Date.now,
|
||||||
|
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||||
|
confirmDiscard: async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||||
|
'重新加载页面?',
|
||||||
|
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning' },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discardUnsavedWork: () => unsaved.clear(),
|
||||||
|
navigate: (target) => window.location.assign(target),
|
||||||
|
showError: (message) => ElMessage.error(message),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
router.afterEach((_to, _from, failure) => {
|
||||||
|
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
|
||||||
|
})
|
||||||
|
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({
|
|||||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
||||||
const plan = {
|
const plan = {
|
||||||
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
|
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
|
||||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
|
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
|
||||||
@@ -38,14 +38,14 @@ describe('platform container loading API', () => {
|
|||||||
it('normalizes paged container ids and numeric metadata', async () => {
|
it('normalizes paged container ids and numeric metadata', async () => {
|
||||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
|
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
|
||||||
const result = await listContainers({ page: 1, size: 20 })
|
const result = await listContainers({ page: 1, size: 20 })
|
||||||
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 })
|
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 })
|
||||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses full-edit and transport whitelist request shapes', async () => {
|
it('uses full-edit and transport whitelist request shapes', async () => {
|
||||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
|
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
|
||||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
|
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
|
||||||
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
|
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
|
||||||
await updateContainer('7', 2, data)
|
await updateContainer('7', 2, data)
|
||||||
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
|
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
|
||||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
|
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
|||||||
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
|
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
|
||||||
customerId: optionalId(raw.customerId),
|
customerId: optionalId(raw.customerId),
|
||||||
shippingCompanyId: optionalId(raw.shippingCompanyId),
|
shippingCompanyId: optionalId(raw.shippingCompanyId),
|
||||||
|
terminalTruckingCompanyId: optionalId(raw.terminalTruckingCompanyId),
|
||||||
emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion),
|
emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion),
|
||||||
version: numberValue(raw.version),
|
version: numberValue(raw.version),
|
||||||
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
|
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import ContainersView from './ContainersView.vue'
|
|||||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||||
import { listPlatformPartners } from '@/modules/master-data'
|
import { listPlatformPartners } from '@/modules/master-data'
|
||||||
import { listSuppliers } from '@/modules/suppliers'
|
import { listSuppliers } from '@/modules/suppliers'
|
||||||
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
|
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
|
||||||
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
|
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
|
||||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||||
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||||
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
|
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
|
||||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||||
const accessCan = vi.hoisted(() => vi.fn())
|
const accessCan = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||||
@@ -38,9 +39,10 @@ describe('ContainersView', () => {
|
|||||||
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||||
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||||
|
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||||
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
|
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
|
||||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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(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: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ describe('ContainersView', () => {
|
|||||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||||
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
||||||
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
|
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
|
||||||
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||||||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||||
}
|
}
|
||||||
vi.mocked(listContainers).mockResolvedValueOnce({
|
vi.mocked(listContainers).mockResolvedValueOnce({
|
||||||
@@ -104,7 +106,7 @@ describe('ContainersView', () => {
|
|||||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||||
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
||||||
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
|
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
|
||||||
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||||||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||||
}
|
}
|
||||||
vi.mocked(listContainers).mockResolvedValueOnce({
|
vi.mocked(listContainers).mockResolvedValueOnce({
|
||||||
@@ -264,17 +266,159 @@ describe('ContainersView', () => {
|
|||||||
expect(source).not.toContain('v-model="form.maxWeightKg"')
|
expect(source).not.toContain('v-model="form.maxWeightKg"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('links a simulation to a released requirement and records the port container number', () => {
|
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
vi.mocked(listContainers).mockResolvedValue({ records: [{
|
||||||
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
|
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
|
||||||
expect(source).toContain('booking.containerRequirements')
|
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||||
expect(source).toContain('booking.containers.filter((container) => container.requirementId === requirement.requirementId).length')
|
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'EDITING',
|
||||||
expect(source).toContain('请输入港口取得的真实集装箱号')
|
capacityCbm: 86, maxWeightKg: 29000, version: 7,
|
||||||
expect(source).toContain('oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo')
|
}], page: 1, size: 20, total: 1, pages: 1 } as never)
|
||||||
expect(source).toContain(':disabled="candidate.disabled"')
|
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||||
expect(source).not.toContain('realContainerBookingId')
|
const router = createTestRouter()
|
||||||
expect(source).not.toContain('MERGED')
|
await router.push('/'); await router.isReady()
|
||||||
expect(source).not.toContain('解除订舱关联')
|
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||||
|
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
|
||||||
|
expect((wrapper.vm as unknown as { linkCandidates: unknown[] }).linkCandidates).toEqual([])
|
||||||
|
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8'))
|
||||||
|
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
|
||||||
|
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||||
|
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'WAIT_LOADING',
|
||||||
|
capacityCbm: 86, maxWeightKg: 29000, version: 7,
|
||||||
|
}], page: 1, size: 20, total: 1, pages: 1 } as never)
|
||||||
|
vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }))
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/'); await router.isReady()
|
||||||
|
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const linkAction = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||||
|
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
|
||||||
|
|
||||||
|
linkAction.click()
|
||||||
|
linkAction.click()
|
||||||
|
|
||||||
|
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
|
||||||
|
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
|
||||||
|
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||||
|
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HC', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HC × 1')
|
||||||
|
expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards a candidate response from a closed link session', async () => {
|
||||||
|
let resolveFirst!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
|
||||||
|
let resolveSecond!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
|
||||||
|
vi.mocked(listOceanBookingLinkCandidates)
|
||||||
|
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
|
||||||
|
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve }))
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/'); await router.isReady()
|
||||||
|
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||||
|
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.findAllComponents(AppDialog)
|
||||||
|
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||||
|
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||||
|
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
|
||||||
|
const candidateLabels = (wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.map((candidate) => candidate.label)
|
||||||
|
expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true)
|
||||||
|
expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
|
||||||
|
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||||
|
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/'); await router.isReady()
|
||||||
|
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
|
||||||
|
terminalTruckingCompanyId: '9007199254740998',
|
||||||
|
terminalTruckingCompany: '测试拖车供应商',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the trucking supplier id and name together', async () => {
|
||||||
|
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||||
|
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/'); await router.isReady()
|
||||||
|
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
|
||||||
|
truckingSelect!.vm.$emit('update:modelValue', '')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
|
||||||
|
terminalTruckingCompanyId: null,
|
||||||
|
terminalTruckingCompany: null,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
|
||||||
|
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
|
||||||
|
vi.mocked(updateContainer).mockClear()
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/'); await router.isReady()
|
||||||
|
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
|
||||||
|
expect(truckingSelect?.props('modelValue')).toBe('')
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(updateContainer).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('原拖车供应商已不可用,请重新选择合作中的拖车供应商')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|||||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||||
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
import { listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
@@ -92,8 +92,10 @@ const linkCandidateKey = ref('')
|
|||||||
const linkContainerNo = ref('')
|
const linkContainerNo = ref('')
|
||||||
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
|
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
|
||||||
const selectedTruckingSupplierId = ref('')
|
const selectedTruckingSupplierId = ref('')
|
||||||
|
const unresolvedTruckingSupplierName = ref<string | null>(null)
|
||||||
let listSequence = 0
|
let listSequence = 0
|
||||||
let detailSequence = 0
|
let detailSequence = 0
|
||||||
|
let linkSequence = 0
|
||||||
|
|
||||||
function emptyForm(): ContainerForm {
|
function emptyForm(): ContainerForm {
|
||||||
return {
|
return {
|
||||||
@@ -101,7 +103,7 @@ function emptyForm(): ContainerForm {
|
|||||||
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null,
|
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null,
|
||||||
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
|
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
|
||||||
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
|
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
|
||||||
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
|
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null,
|
||||||
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
|
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
|
||||||
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@ const form = reactive<ContainerForm>(emptyForm())
|
|||||||
const rules: FormRules<ContainerForm> = {
|
const rules: FormRules<ContainerForm> = {
|
||||||
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
||||||
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
||||||
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
||||||
@@ -161,8 +163,8 @@ const destinationWarehouseValue = computed(() => form.destinationWarehouseCode |
|
|||||||
const queryFields = computed<QueryField[]>(() => [
|
const queryFields = computed<QueryField[]>(() => [
|
||||||
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
|
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
|
||||||
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
|
{ key: 'containerType', type: 'select', label: '柜型', width: 140, placeholder: '请选择箱型', options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
|
||||||
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
|
{ key: 'status', type: 'select', label: '排柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
|
||||||
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
|
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
|
||||||
])
|
])
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
@@ -205,10 +207,30 @@ function replaceForm(source: ContainerBookingResource | null) {
|
|||||||
Object.assign(form, emptyForm(), source || {})
|
Object.assign(form, emptyForm(), source || {})
|
||||||
const specification = containerSpecification(form.containerType)
|
const specification = containerSpecification(form.containerType)
|
||||||
if (specification) applyContainerSpecification(form, specification.type)
|
if (specification) applyContainerSpecification(form, specification.type)
|
||||||
selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || ''
|
reconcileTruckingSupplier()
|
||||||
|
}
|
||||||
|
function reconcileTruckingSupplier() {
|
||||||
|
const supplierId = optional(form.terminalTruckingCompanyId)
|
||||||
|
const supplierName = optional(form.terminalTruckingCompany) || unresolvedTruckingSupplierName.value
|
||||||
|
const supplier = supplierId
|
||||||
|
? truckingCompanies.value.find((item) => item.id === supplierId)
|
||||||
|
: truckingCompanies.value.find((item) => item.name === supplierName)
|
||||||
|
selectedTruckingSupplierId.value = supplier?.id || ''
|
||||||
|
unresolvedTruckingSupplierName.value = supplier ? null : supplierName
|
||||||
|
if (supplier) {
|
||||||
|
form.terminalTruckingCompanyId = supplier.id
|
||||||
|
form.terminalTruckingCompany = supplier.name
|
||||||
|
} else if (!supplierName) {
|
||||||
|
form.terminalTruckingCompanyId = null
|
||||||
|
form.terminalTruckingCompany = null
|
||||||
|
} else {
|
||||||
|
form.terminalTruckingCompanyId = null
|
||||||
|
form.terminalTruckingCompany = supplierName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function payload(): ContainerSaveRequest {
|
function payload(): ContainerSaveRequest {
|
||||||
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
|
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
|
||||||
|
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
|
||||||
return {
|
return {
|
||||||
containerNo: optional(form.containerNo),
|
containerNo: optional(form.containerNo),
|
||||||
warehouseId: form.warehouseId,
|
warehouseId: form.warehouseId,
|
||||||
@@ -219,7 +241,9 @@ function payload(): ContainerSaveRequest {
|
|||||||
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
|
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
|
||||||
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
|
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
|
||||||
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
|
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
|
||||||
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg, terminalTruckingCompany: optional(form.terminalTruckingCompany),
|
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg,
|
||||||
|
terminalTruckingCompanyId: selectedTruckingSupplier?.id || null,
|
||||||
|
terminalTruckingCompany: selectedTruckingSupplier?.name || null,
|
||||||
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
|
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
|
||||||
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
|
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
|
||||||
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
|
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
|
||||||
@@ -230,8 +254,11 @@ function partnerLabel(partner: PlatformPartnerResource) {
|
|||||||
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
|
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
|
||||||
}
|
}
|
||||||
function selectTruckingSupplier(supplierId: string) {
|
function selectTruckingSupplier(supplierId: string) {
|
||||||
|
if (!supplierId && unresolvedTruckingSupplierName.value) return
|
||||||
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
|
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
|
||||||
selectedTruckingSupplierId.value = supplier?.id || ''
|
selectedTruckingSupplierId.value = supplier?.id || ''
|
||||||
|
unresolvedTruckingSupplierName.value = null
|
||||||
|
form.terminalTruckingCompanyId = supplier?.id || null
|
||||||
form.terminalTruckingCompany = supplier?.name || null
|
form.terminalTruckingCompany = supplier?.name || null
|
||||||
}
|
}
|
||||||
function portLabel(port: PortOption) {
|
function portLabel(port: PortOption) {
|
||||||
@@ -300,7 +327,10 @@ async function loadBusinessOptions() {
|
|||||||
])
|
])
|
||||||
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
|
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
|
||||||
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
|
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
|
||||||
if (supplierResult.status === 'fulfilled') truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
if (supplierResult.status === 'fulfilled') {
|
||||||
|
truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
||||||
|
if (form.id) reconcileTruckingSupplier()
|
||||||
|
}
|
||||||
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
|
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
|
||||||
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
|
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
|
||||||
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
|
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
|
||||||
@@ -351,14 +381,19 @@ async function saveSimulation() {
|
|||||||
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
|
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
|
||||||
finally { simulationSaving.value = false }
|
finally { simulationSaving.value = false }
|
||||||
}
|
}
|
||||||
async function loadReleasedBookings() {
|
|
||||||
const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
|
|
||||||
if (first.pages <= 1) return first.records
|
|
||||||
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
|
|
||||||
return [first, ...rest].flatMap((result) => result.records)
|
|
||||||
}
|
|
||||||
async function openLink(source: object) {
|
async function openLink(source: object) {
|
||||||
|
if (linkLoading.value) return
|
||||||
const row = source as ContainerBookingResource
|
const row = source as ContainerBookingResource
|
||||||
|
const warehouseId = optional(row.warehouseId)
|
||||||
|
const channelCode = optional(row.channelCode)
|
||||||
|
if (!warehouseId || !channelCode || !optional(row.containerType)) {
|
||||||
|
linkTarget.value = row
|
||||||
|
linkCandidates.value = []
|
||||||
|
linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
|
||||||
|
linkOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sequence = ++linkSequence
|
||||||
linkTarget.value = row
|
linkTarget.value = row
|
||||||
linkCandidateKey.value = ''
|
linkCandidateKey.value = ''
|
||||||
linkContainerNo.value = ''
|
linkContainerNo.value = ''
|
||||||
@@ -367,27 +402,23 @@ async function openLink(source: object) {
|
|||||||
linkOpen.value = true
|
linkOpen.value = true
|
||||||
linkLoading.value = true
|
linkLoading.value = true
|
||||||
try {
|
try {
|
||||||
const bookings = await loadReleasedBookings()
|
const result = await listOceanBookingLinkCandidates({
|
||||||
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
|
warehouseId, channelCode, containerType: row.containerType,
|
||||||
linkCandidates.value = details.flatMap((booking) => booking.containerRequirements
|
page: 1, size: 200,
|
||||||
.map((requirement) => ({ ...requirement, remainingQuantity: Math.max(0, Number(requirement.quantity) - booking.containers.filter((container) => container.requirementId === requirement.requirementId).length) }))
|
})
|
||||||
.filter((requirement) => Boolean(requirement.requirementId) && Number(requirement.remainingQuantity) > 0)
|
if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
|
||||||
.map((requirement) => {
|
linkCandidates.value = result.records.map((candidate) => ({
|
||||||
const specificationMatches = booking.warehouseId === row.warehouseId
|
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||||
&& booking.channelCode === row.channelCode
|
oceanBookingId: candidate.oceanBookingId,
|
||||||
&& requirement.containerType === row.containerType
|
requirementId: candidate.requirementId,
|
||||||
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
|
disabled: false,
|
||||||
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)
|
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||||
return {
|
}))
|
||||||
key: `${booking.id}:${requirement.requirementId}`,
|
} catch (error) {
|
||||||
oceanBookingId: booking.id,
|
if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
|
||||||
requirementId: String(requirement.requirementId),
|
} finally {
|
||||||
disabled: !specificationMatches,
|
if (sequence === linkSequence) linkLoading.value = false
|
||||||
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} × ${requirement.remainingQuantity} · ${booking.customerName || '未关联客户'} · ${booking.warehouseName || '仓库待补'} · ${booking.channelName || '渠道待补'}${specificationMatches ? '' : '(与当前柜规格不一致)'}`,
|
}
|
||||||
}
|
|
||||||
}))
|
|
||||||
} catch (error) { linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败') }
|
|
||||||
finally { linkLoading.value = false }
|
|
||||||
}
|
}
|
||||||
async function saveLink() {
|
async function saveLink() {
|
||||||
const target = linkTarget.value
|
const target = linkTarget.value
|
||||||
@@ -521,6 +552,10 @@ async function openEdit(source: object) {
|
|||||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
|
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (unresolvedTruckingSupplierName.value) {
|
||||||
|
editorError.value = { message: '原拖车供应商已不可用,请重新选择合作中的拖车供应商' }
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!(await formRef.value?.validate().catch(() => false))) return
|
if (!(await formRef.value?.validate().catch(() => false))) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
editorError.value = null
|
editorError.value = null
|
||||||
@@ -564,6 +599,11 @@ function goContainerProcess(source: object) {
|
|||||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||||
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
|
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
|
||||||
})
|
})
|
||||||
|
watch(linkOpen, (open) => {
|
||||||
|
if (open) return
|
||||||
|
linkSequence += 1
|
||||||
|
linkLoading.value = false
|
||||||
|
})
|
||||||
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
|
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -616,8 +656,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
|||||||
|
|
||||||
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
|
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
|
||||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
|
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
|
||||||
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无可关联的已放舱订舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
|
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
|
||||||
<p class="dialog-hint">仓库、渠道、柜型、容量和限重必须与当前模拟柜一致;不一致的候选不可选择。</p>
|
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||||
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const container = {
|
|||||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||||
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
||||||
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
|
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
|
||||||
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||||||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||||
}
|
}
|
||||||
const plan = {
|
const plan = {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const container = {
|
|||||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||||
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
||||||
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
|
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
|
||||||
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||||||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||||
}
|
}
|
||||||
const planResponse = {
|
const planResponse = {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface ContainerBookingResource {
|
|||||||
destinationWarehouseName: string | null
|
destinationWarehouseName: string | null
|
||||||
capacityCbm: number | null
|
capacityCbm: number | null
|
||||||
maxWeightKg: number | null
|
maxWeightKg: number | null
|
||||||
|
terminalTruckingCompanyId: string | null
|
||||||
terminalTruckingCompany: string | null
|
terminalTruckingCompany: string | null
|
||||||
loadingAt: string | null
|
loadingAt: string | null
|
||||||
estimatedSailingAt: string | null
|
estimatedSailingAt: string | null
|
||||||
@@ -205,6 +206,7 @@ export interface ContainerSaveRequest {
|
|||||||
destinationWarehouseName: string | null
|
destinationWarehouseName: string | null
|
||||||
capacityCbm: number | null
|
capacityCbm: number | null
|
||||||
maxWeightKg: number | null
|
maxWeightKg: number | null
|
||||||
|
terminalTruckingCompanyId: string | null
|
||||||
terminalTruckingCompany: string | null
|
terminalTruckingCompany: string | null
|
||||||
loadingAt: string | null
|
loadingAt: string | null
|
||||||
estimatedSailingAt: string | null
|
estimatedSailingAt: string | null
|
||||||
|
|||||||
@@ -294,6 +294,16 @@ function validateDraft() {
|
|||||||
form.items.forEach((item, index) => {
|
form.items.forEach((item, index) => {
|
||||||
const itemErrors: string[] = []
|
const itemErrors: string[] = []
|
||||||
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
|
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
|
||||||
|
const dimensions = [
|
||||||
|
['长', item.lengthCm],
|
||||||
|
['宽', item.widthCm],
|
||||||
|
['高', item.heightCm],
|
||||||
|
] as const
|
||||||
|
for (const [label, value] of dimensions) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) itemErrors.push(`${label}必须是大于 0 的有效数值`)
|
||||||
|
else if (value > 999_999_999.999) itemErrors.push(`${label}超出系统允许上限 999,999,999.999 cm`)
|
||||||
|
else if (Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push(`${label}最多保留 3 位小数`)
|
||||||
|
}
|
||||||
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
|
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
|
||||||
})
|
})
|
||||||
if (general.length) errors.general = general
|
if (general.length) errors.general = general
|
||||||
@@ -310,7 +320,17 @@ function validateDraft() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setOperationError(error: unknown, fallback: string) {
|
function setOperationError(error: unknown, fallback: string) {
|
||||||
const details = getApiErrorDetails(error, fallback)
|
const source = getApiErrorDetails(error, fallback)
|
||||||
|
const numericOverflow = /data truncation|out of range|numeric value|decimal|数值.*(过大|超出)|尺寸.*(过大|超限)/i.test(source.message)
|
||||||
|
const largestItem = numericOverflow
|
||||||
|
? [...form.items].sort((left, right) => Math.max(right.lengthCm, right.widthCm, right.heightCm) - Math.max(left.lengthCm, left.widthCm, left.heightCm))[0]
|
||||||
|
: undefined
|
||||||
|
const details = numericOverflow ? {
|
||||||
|
...source,
|
||||||
|
message: largestItem
|
||||||
|
? `箱规尺寸或箱数超出系统允许范围,请检查箱号 ${largestItem.boxCode || largestItem.itemNo}(${formatNumber(largestItem.lengthCm)} × ${formatNumber(largestItem.widthCm)} × ${formatNumber(largestItem.heightCm)} cm)后重试`
|
||||||
|
: '箱规尺寸或箱数超出系统允许范围,请检查长、宽、高和箱数后重试',
|
||||||
|
} : source
|
||||||
const conflict = details.message.includes('版本已变化')
|
const conflict = details.message.includes('版本已变化')
|
||||||
|| details.message.includes('并发修改')
|
|| details.message.includes('并发修改')
|
||||||
conflictDetected.value = conflict
|
conflictDetected.value = conflict
|
||||||
@@ -361,6 +381,7 @@ async function saveDraft() {
|
|||||||
|
|
||||||
async function submitForReview() {
|
async function submitForReview() {
|
||||||
if (!maySubmit.value || !form.measurementId) return
|
if (!maySubmit.value || !form.measurementId) return
|
||||||
|
if (!validateDraft()) return
|
||||||
if (isDirty.value) {
|
if (isDirty.value) {
|
||||||
ElMessage.warning('请先保存当前修改,再提交审核')
|
ElMessage.warning('请先保存当前修改,再提交审核')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
|
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
|
||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
|
|
||||||
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||||
@@ -14,13 +14,23 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
|||||||
|
|
||||||
describe('ocean bookings api', () => {
|
describe('ocean bookings api', () => {
|
||||||
it('normalizes pagination and long ids', async () => {
|
it('normalizes pagination and long ids', async () => {
|
||||||
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '3' }], page: '2', size: '20', total: '21', pages: '2' } })
|
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, bookingNo: 'OB-260825-0183', customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '1' }], page: '2', size: '20', total: '21', pages: '2' } })
|
||||||
const result = await listOceanBookings({ page: 1, size: 10 })
|
const result = await listOceanBookings({ page: 1, size: 10 })
|
||||||
expect(result.page).toBe(2)
|
expect(result.page).toBe(2)
|
||||||
expect(result.total).toBe(21)
|
expect(result.total).toBe(21)
|
||||||
expect(result.records[0]?.id).toBe('123')
|
expect(result.records[0]?.id).toBe('123')
|
||||||
expect(result.records[0]?.customerId).toBe('456')
|
expect(result.records[0]?.customerId).toBe('456')
|
||||||
expect(result.records[0]).toMatchObject({ shipperName: '发货企业', consigneeName: '收货企业', containerCount: 3 })
|
expect(result.records[0]).toMatchObject({ bookingNo: 'OB-260825-0183', shipperName: '发货企业', consigneeName: '收货企业', containerCount: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
|
||||||
|
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HC', capacityCbm: '76', maxWeightKg: '28000', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||||
|
const query = { warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 }
|
||||||
|
|
||||||
|
const result = await listOceanBookingLinkCandidates(query)
|
||||||
|
|
||||||
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/link-candidates', { params: query })
|
||||||
|
expect(result.records[0]).toMatchObject({ oceanBookingId: '9007199254740992', requirementId: '9007199254740994', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
import type { OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||||
|
|
||||||
const basePath = '/platform/v1/ocean-bookings'
|
const basePath = '/platform/v1/ocean-bookings'
|
||||||
|
|
||||||
@@ -59,6 +59,27 @@ export async function listOceanBookings(query: OceanBookingQuery) {
|
|||||||
const result = unwrapApiResponse(response)
|
const result = unwrapApiResponse(response)
|
||||||
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
|
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
|
||||||
}
|
}
|
||||||
|
export async function listOceanBookingLinkCandidates(query: OceanBookingLinkCandidateQuery) {
|
||||||
|
const response = await http.get<ApiEnvelope<OceanBookingLinkCandidatePage>, ApiEnvelope<OceanBookingLinkCandidatePage>>(`${basePath}/link-candidates`, { params: query })
|
||||||
|
const result = unwrapApiResponse(response)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
records: (result.records || []).map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
oceanBookingId: String(candidate.oceanBookingId),
|
||||||
|
requirementId: String(candidate.requirementId),
|
||||||
|
capacityCbm: Number(candidate.capacityCbm),
|
||||||
|
maxWeightKg: Number(candidate.maxWeightKg),
|
||||||
|
requiredQuantity: Number(candidate.requiredQuantity),
|
||||||
|
allocatedQuantity: Number(candidate.allocatedQuantity),
|
||||||
|
remainingQuantity: Number(candidate.remainingQuantity),
|
||||||
|
})),
|
||||||
|
page: pageNumber(result.page, query.page),
|
||||||
|
size: pageNumber(result.size, query.size),
|
||||||
|
total: pageNumber(result.total, 0),
|
||||||
|
pages: pageNumber(result.pages, 0),
|
||||||
|
} satisfies OceanBookingLinkCandidatePage
|
||||||
|
}
|
||||||
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
|
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
|
||||||
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||||
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { oceanBookingRoutes } from './routes'
|
export { oceanBookingRoutes } from './routes'
|
||||||
export { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
|
export { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
|
||||||
export * from './status'
|
export * from './status'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
|
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
import { getApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails } from '@/shared/api/http'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
@@ -52,6 +53,7 @@ const containerKindOptions = [
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
|
const workspace = useWorkspaceTabsStore()
|
||||||
const id = computed(() => String(route.params.bookingId || ''))
|
const id = computed(() => String(route.params.bookingId || ''))
|
||||||
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
|
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
|
||||||
const detail = ref<OceanBookingDetail | null>(null)
|
const detail = ref<OceanBookingDetail | null>(null)
|
||||||
@@ -220,8 +222,12 @@ async function save() {
|
|||||||
const saved = isNew.value
|
const saved = isNew.value
|
||||||
? await createOceanBooking({ expectedVersion: 0, data: payload })
|
? await createOceanBooking({ expectedVersion: 0, data: payload })
|
||||||
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
|
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
|
||||||
ElMessage.success('订舱草稿已保存')
|
ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存')
|
||||||
if (isNew.value) await router.replace(`/ocean-bookings/${saved.id}`)
|
if (isNew.value) {
|
||||||
|
workspace.removeByIds(['ocean-booking-create'])
|
||||||
|
await router.replace('/ocean-bookings')
|
||||||
|
return
|
||||||
|
}
|
||||||
applyDetail(saved)
|
applyDetail(saved)
|
||||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import ElementPlus from 'element-plus'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
import OceanBookingsView from './OceanBookingsView.vue'
|
||||||
|
import { listOceanBookings } from '../api/platformOceanBookings'
|
||||||
|
|
||||||
|
const routerPush = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
|
||||||
|
vi.mock('../api/platformOceanBookings', () => ({ listOceanBookings: vi.fn() }))
|
||||||
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
|
||||||
|
|
||||||
describe('OceanBookingsView', () => {
|
describe('OceanBookingsView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||||
|
})
|
||||||
it('keeps the operation column first and left fixed', () => {
|
it('keeps the operation column first and left fixed', () => {
|
||||||
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'"))
|
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'"))
|
||||||
expect(source).toContain("fixed: 'left'")
|
expect(source).toContain("fixed: 'left'")
|
||||||
@@ -41,6 +55,24 @@ describe('OceanBookingsView', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('displays the server containerCount as the booking requirement total without detail N+1', async () => {
|
||||||
|
vi.mocked(listOceanBookings).mockResolvedValueOnce({ records: [{
|
||||||
|
id: '183', bookingNo: 'OB-260825-0183', status: 'RELEASED', containerCount: 1,
|
||||||
|
customerName: '回归客户', vesselName: null, voyageNo: null, etd: null, eta: null, updatedAt: null,
|
||||||
|
}], page: 1, size: 20, total: 1, pages: 1 } as never)
|
||||||
|
const wrapper = mount(OceanBookingsView, { global: { plugins: [ElementPlus] } })
|
||||||
|
|
||||||
|
await (wrapper.vm as unknown as { load: () => Promise<void> }).load()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const tableText = wrapper.find('.el-table__body-wrapper').text()
|
||||||
|
expect(tableText).toContain('OB-260825-0183')
|
||||||
|
expect(tableText).toContain('1')
|
||||||
|
expect(source).toContain("label: '订舱柜量'")
|
||||||
|
expect(source).not.toContain('getOceanBooking')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('loads active customers by name and stays usable on narrow screens', () => {
|
it('loads active customers by name and stays usable on narrow screens', () => {
|
||||||
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
||||||
expect(source).toContain('label: customer.tenantName')
|
expect(source).toContain('label: customer.tenantName')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
@@ -41,7 +41,7 @@ const columns = [
|
|||||||
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
|
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
|
||||||
{ prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' },
|
{ prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' },
|
||||||
{ prop: 'consigneeName', label: '提单收货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.consigneeName || '—' },
|
{ prop: 'consigneeName', label: '提单收货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.consigneeName || '—' },
|
||||||
{ prop: 'containerCount', label: '柜量', width: 90, align: 'right' },
|
{ prop: 'containerCount', label: '订舱柜量', width: 100, align: 'right' },
|
||||||
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
|
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
|
||||||
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
|
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
|
||||||
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
|
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
|
||||||
@@ -58,7 +58,8 @@ async function load() { loading.value = true; error.value = null; try { const ke
|
|||||||
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
||||||
function createBooking() { void router.push('/ocean-bookings/new') }
|
function createBooking() { void router.push('/ocean-bookings/new') }
|
||||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||||
watch([page, pageSize], () => void load()); onMounted(() => { void load(); void loadCustomers() })
|
watch([page, pageSize], () => void load()); onMounted(() => void loadCustomers())
|
||||||
|
onActivated(() => void load())
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AdminPage as="div" class="ocean-bookings-page">
|
<AdminPage as="div" class="ocean-bookings-page">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface OceanBookingDraftData {
|
|||||||
export interface OceanBookingResource {
|
export interface OceanBookingResource {
|
||||||
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
||||||
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
|
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
|
||||||
|
/** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */
|
||||||
shipperName: string | null; consigneeName: string | null; containerCount: number
|
shipperName: string | null; consigneeName: string | null; containerCount: number
|
||||||
channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null
|
channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null
|
||||||
shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null
|
shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null
|
||||||
@@ -52,5 +53,27 @@ export interface OceanBookingDetail extends OceanBookingResource { oceanBooking:
|
|||||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
|
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
|
||||||
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
|
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
|
||||||
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
|
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
|
||||||
|
export interface OceanBookingLinkCandidate {
|
||||||
|
oceanBookingId: string
|
||||||
|
bookingNo: string
|
||||||
|
requirementId: string
|
||||||
|
containerType: string
|
||||||
|
capacityCbm: number
|
||||||
|
maxWeightKg: number
|
||||||
|
requiredQuantity: number
|
||||||
|
allocatedQuantity: number
|
||||||
|
remainingQuantity: number
|
||||||
|
customerName: string
|
||||||
|
warehouseName: string
|
||||||
|
channelName: string
|
||||||
|
}
|
||||||
|
export interface OceanBookingLinkCandidatePage { records: OceanBookingLinkCandidate[]; page: number; size: number; total: number; pages: number }
|
||||||
|
export interface OceanBookingLinkCandidateQuery {
|
||||||
|
warehouseId: string
|
||||||
|
channelCode: string
|
||||||
|
containerType: string
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
|
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
|
||||||
export interface OceanBookingReleaseRequest { expectedVersion: number }
|
export interface OceanBookingReleaseRequest { expectedVersion: number }
|
||||||
|
|||||||
@@ -184,8 +184,6 @@ async function loadWarehouses() {
|
|||||||
|
|
||||||
async function loadPallets() {
|
async function loadPallets() {
|
||||||
const sequence = ++palletSequence
|
const sequence = ++palletSequence
|
||||||
rows.value = []
|
|
||||||
total.value = 0
|
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user