Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
13 changed files with 430 additions and 44 deletions
Showing only changes of commit 4f65948347 - Show all commits
+1 -1
View File
@@ -47,7 +47,7 @@
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关逐资料完成,全买单柜使用 `complete-all-buy` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
@@ -0,0 +1,30 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import SmartLoadingDialog from './SmartLoadingDialog.vue'
describe('SmartLoadingDialog', () => {
afterEach(() => { document.body.innerHTML = '' })
it('expands to the viewport and can return to the normal dialog size', async () => {
const wrapper = mount(SmartLoadingDialog, {
attachTo: document.body,
props: { modelValue: true },
global: {
plugins: [ElementPlus],
stubs: { GlobalSmartLoadingView: { template: '<div>智能装柜工作台</div>' } },
},
})
await flushPromises()
expect(wrapper.findAll('.smart-loading-dialog__warehouses [role="tab"]').map((tab) => tab.text())).toEqual(['义乌仓', '东莞仓'])
const expand = wrapper.get('button[aria-label="展开全屏"]')
await expand.trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull()
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
})
})
@@ -1,8 +1,25 @@
<script setup lang="ts">
import { Maximize2, Minimize2 } from '@lucide/vue'
import { ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
import { SMART_LOADING_WAREHOUSE_GROUPS } from '../smartLoadingChannelGroups'
const open = defineModel<boolean>({ default: false })
const fullscreen = ref(false)
const warehouseCode = ref('')
const workbench = ref<InstanceType<typeof GlobalSmartLoadingView> | null>(null)
watch(open, (value) => {
if (!value) {
fullscreen.value = false
warehouseCode.value = ''
}
})
function selectWarehouse(code: string) {
void workbench.value?.selectWarehouse(code)
}
</script>
<template>
@@ -11,18 +28,44 @@ const open = defineModel<boolean>({ default: false })
class="smart-loading-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
:fullscreen="fullscreen"
cancel-text="关闭"
:show-confirm="false"
destroy-on-close
>
<GlobalSmartLoadingView embedded />
<template #header>
<div class="smart-loading-dialog__header">
<div class="smart-loading-dialog__heading">
<span>智能装柜</span>
<el-tabs :model-value="warehouseCode" class="smart-loading-dialog__warehouses" aria-label="装柜仓库" @tab-change="selectWarehouse(String($event))">
<el-tab-pane v-for="warehouse in SMART_LOADING_WAREHOUSE_GROUPS" :key="warehouse.code" :label="warehouse.name" :name="warehouse.code" />
</el-tabs>
</div>
<el-tooltip :content="fullscreen ? '退出全屏' : '展开全屏'" placement="bottom">
<el-button text :aria-label="fullscreen ? '退出全屏' : '展开全屏'" @click="fullscreen = !fullscreen">
<Minimize2 v-if="fullscreen" :size="18" />
<Maximize2 v-else :size="18" />
</el-button>
</el-tooltip>
</div>
</template>
<GlobalSmartLoadingView ref="workbench" v-model:warehouse-code="warehouseCode" embedded />
</AppDialog>
</template>
<style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw); max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog.app-dialog.el-dialog.is-fullscreen) { width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
:global(.smart-loading-dialog .el-dialog__body) { padding: 0 16px 16px; }
:global(.smart-loading-dialog .app-dialog__body) { min-width: 0; }
.smart-loading-dialog__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
.smart-loading-dialog__heading { display: flex; min-width: 0; align-items: center; gap: 24px; }
.smart-loading-dialog__heading > span { flex: 0 0 auto; }
.smart-loading-dialog__warehouses { min-width: 0; }
.smart-loading-dialog__warehouses :deep(.el-tabs__header) { margin: 0; }
.smart-loading-dialog__warehouses :deep(.el-tabs__nav-wrap::after), .smart-loading-dialog__warehouses :deep(.el-tabs__content) { display: none; }
.smart-loading-dialog__warehouses :deep(.el-tabs__item) { height: 38px; padding-inline: 16px; font-size: 14px; font-weight: 600; }
.smart-loading-dialog__header :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
@media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: calc(100vw - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
:global(.smart-loading-dialog .el-dialog__body) { padding: 0 8px 8px; }
@@ -87,7 +87,9 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
expect(wrapper.text()).toContain('候选装柜明细')
expect(wrapper.text()).toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.findAll('.channel-tabs [role="tab"]')).toHaveLength(9)
expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('另一企业')
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
expect(wrapper.text()).not.toContain('LP-99')
@@ -113,7 +115,8 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('纽约EXX海铁')
expect(wrapper.text()).toContain('EXX')
expect(wrapper.text()).not.toContain('纽约EXX海铁')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
@@ -157,7 +160,7 @@ describe('GlobalSmartLoadingView', () => {
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待补录')
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('渠道:美森正班')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HC')
expect(option.text()).not.toContain('2088475410134773762')
@@ -184,7 +187,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('MSCU-2')
expect(wrapper.text()).not.toContain('个柜次')
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '合德')
expect(tab).toBeDefined()
await tab!.trigger('click')
await flushPromises()
@@ -195,6 +198,78 @@ describe('GlobalSmartLoadingView', () => {
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
})
it('merges detailed pricing channels into the same confirmed display group', async () => {
const parcelContainer = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-2',
containerNo: 'MSCU-2',
channelCode: 'MATSON_CLX_PARCEL',
channelName: '美森正班海派',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, parcelContainer], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(2)
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('MSCU-2')
expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).not.toContain('美森正班海派')
})
it('isolates Yiwu and Dongguan containers when the host changes the warehouse', async () => {
const dongguanContainer = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-DG',
containerNo: 'MSCU-DG',
warehouseId: '18',
warehouseName: '东莞仓',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanContainer], page: 1, size: 100, total: 2, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).not.toContain('MSCU-DG')
await (wrapper.vm as unknown as { selectWarehouse: (code: string) => Promise<void> }).selectWarehouse('DONGGUAN')
await flushPromises()
expect(wrapper.text()).toContain('MSCU-DG')
expect(wrapper.text()).not.toContain('MSCU-1')
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(dongguanContainer.id, dongguanContainer.warehouseId)
})
it('keeps the selected channel when the warehouse changes even if that warehouse has no matching container', async () => {
const dongguanOvertimeContainer = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-DG-MAX',
containerNo: 'MSCU-DG-MAX',
warehouseId: '18',
warehouseName: '东莞仓',
channelCode: 'MATSON_MAX_TRUCK',
channelName: '美森加班海卡',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanOvertimeContainer], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await (wrapper.vm as unknown as { selectWarehouse: (code: string) => Promise<void> }).selectWarehouse('DONGGUAN')
await flushPromises()
expect(wrapper.get('.channel-tabs [role="tab"][aria-selected="true"]').text()).toBe('美森正班')
expect(wrapper.findAll('.container-option')).toHaveLength(0)
expect(wrapper.text()).not.toContain('MSCU-DG-MAX')
expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(1)
})
it('filters candidates by an active name-only customer selector and accountId', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
@@ -30,8 +30,15 @@ import type {
} from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
import {
SMART_LOADING_CHANNEL_GROUPS,
SMART_LOADING_WAREHOUSE_GROUPS,
resolveSmartLoadingChannelGroup,
resolveSmartLoadingWarehouseGroup,
} from '../smartLoadingChannelGroups'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const activeWarehouseCode = defineModel<string>('warehouseCode', { default: '' })
const access = useAccessPreviewStore()
const router = useRouter()
@@ -66,17 +73,19 @@ const selectedContainer = computed(() => containers.value.find((item) => item.id
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string }>()
for (const item of orchestratableContainers.value) {
if (!item.channelCode) continue
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
})
}
return [...channels.values()]
})
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
)).length)
const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
resolveSmartLoadingWarehouseGroup(item.warehouseName)
&& resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
)).length)
const unmappedWarehouseContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
!resolveSmartLoadingWarehouseGroup(item.warehouseName)
)).length)
const warehouseTabs = computed(() => SMART_LOADING_WAREHOUSE_GROUPS)
const channelTabs = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const activeWarehouse = computed(() => warehouseTabs.value.find((item) => item.code === activeWarehouseCode.value) || null)
const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null)
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
@@ -194,8 +203,9 @@ const queryFields = computed<QueryField[]>(() => [
const filteredContainers = computed(() => containers.value.filter((item) => (
['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)
&& resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === activeWarehouseCode.value
&& Boolean(activeChannelCode.value)
&& item.channelCode === activeChannelCode.value
&& resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === activeChannelCode.value
)))
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
@@ -204,7 +214,7 @@ const filteredCandidates = computed(() => {
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
: []
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
const channelCode = selectedContainer.value?.channelCode
return displayedCandidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
@@ -287,6 +297,13 @@ function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> =
if (syncContainerStatus) container.planStatus = next.status
}
}
function preferredChannelCode(warehouseCode: string) {
return channelTabs.value.find((group) => orchestratableContainers.value.some((item) => (
resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === warehouseCode
&& resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === group.code
)))?.code || channelTabs.value[0]?.code || ''
}
function clearSelectionData() {
plan.value = null
candidates.value = []
@@ -346,8 +363,13 @@ async function loadAll(preserved: Record<string, number> = {}) {
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!warehouseTabs.value.some((item) => item.code === activeWarehouseCode.value)) {
activeWarehouseCode.value = warehouseTabs.value.find((group) => orchestratableContainers.value.some((item) => (
resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === group.code
)))?.code || warehouseTabs.value[0]?.code || ''
}
if (!channelTabs.value.some((item) => item.code === activeChannelCode.value)) {
activeChannelCode.value = channelTabs.value[0]?.code || ''
activeChannelCode.value = preferredChannelCode(activeWarehouseCode.value)
}
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedData(preserved)
@@ -374,6 +396,13 @@ async function selectContainer(containerId: string) {
selectedContainerId.value = containerId
await loadSelectedData()
}
async function selectWarehouse(warehouseCode: string) {
if (activeWarehouseCode.value === warehouseCode) return
activeWarehouseCode.value = warehouseCode
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
defineExpose({ selectWarehouse })
async function selectChannel(channelCode: string) {
if (activeChannelCode.value === channelCode) return
activeChannelCode.value = channelCode
@@ -558,10 +587,13 @@ onMounted(() => { void load(); void loadCustomers() })
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-else-if="missingChannelContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-else-if="completedContainerCount" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的渠道未纳入固定 9 类,已隔离`" />
<el-alert v-if="unmappedWarehouseContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedWarehouseContainerCount} 个可编排柜次不属于义乌仓或东莞仓,已隔离`" />
<el-alert v-if="completedContainerCount && !orchestratableContainers.length" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<QueryToolbar
v-model="query"
class="smart-loading-query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query-v2"
@@ -578,7 +610,7 @@ onMounted(() => { void load(); void loadCustomers() })
</LoadErrorState>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeWarehouse?.name || '未选择仓库' }} · {{ activeChannel?.name || '未选择渠道' }}</small></div></header>
<DataTable
v-model:page="candidatePage"
v-model:page-size="candidatePageSize"
@@ -629,7 +661,7 @@ onMounted(() => { void load(); void loadCustomers() })
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>渠道:{{ resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.name || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
@@ -707,9 +739,9 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.global-loading-page { display: grid; gap: 12px; min-width: 0; }
.embedded-smart-loading { min-width: 0; }
.channel-summary-row { display: grid; grid-template-columns: minmax(240px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-summary-row { display: grid; grid-template-columns: minmax(360px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tabs { min-width: 0; }
.channel-tabs :deep(.el-tabs__header) { margin: 0 0 -2px; }
.channel-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
@@ -720,6 +752,7 @@ onMounted(() => { void load(); void loadCustomers() })
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
@@ -764,6 +797,7 @@ onMounted(() => { void load(); void loadCustomers() })
}
@media (max-width: 720px) {
.channel-summary-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
.compact-summary { grid-column: 1 / -1; }
.compact-summary { min-height: 0; justify-content: flex-start; flex-wrap: wrap; gap: 6px 14px; padding: 8px 12px; white-space: normal; }
.capacity-grid { grid-template-columns: 1fr; }
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
SMART_LOADING_CHANNEL_GROUPS,
SMART_LOADING_WAREHOUSE_GROUPS,
resolveSmartLoadingChannelGroup,
resolveSmartLoadingWarehouseGroup,
} from './smartLoadingChannelGroups'
describe('smartLoadingChannelGroups', () => {
it('exposes the nine confirmed display groups in a fixed order', () => {
expect(SMART_LOADING_CHANNEL_GROUPS.map((group) => group.name)).toEqual([
'美森正班', '美森加班', 'EXX', '合德', '洛杉矶普船', '芝加哥普船', '休斯顿普船', '萨凡纳普船', '纽约普船海卡',
])
})
it.each([
['MATSON_CLX_TRUCK', '美森正班海卡', '美森正班'],
['MATSON_CLX_PARCEL', '美森正班海派', '美森正班'],
['BYHP', '美森加班海派', '美森加班'],
['EXX_RAIL_NY', '纽约EXX海铁', 'EXX'],
['HDHK', '合德海卡包税', '合德'],
['OA_LA_FULL_TRUCK', '洛杉矶普船全卡', '洛杉矶普船'],
['OA_PARCEL', '普船海派', '洛杉矶普船'],
['CPHT', '芝加哥普船海铁', '芝加哥普船'],
['HPHK', '休斯顿普船海卡', '休斯顿普船'],
['SPHK', '萨凡纳普船海卡', '萨凡纳普船'],
['NPHK', '纽约普船海卡', '纽约普船海卡'],
])('maps %s / %s into %s', (channelCode, channelName, expected) => {
expect(resolveSmartLoadingChannelGroup(channelCode, channelName)?.name).toBe(expected)
})
it('does not leak an unconfirmed channel into the fixed display groups', () => {
expect(resolveSmartLoadingChannelGroup('UNKNOWN', '未确认新渠道')).toBeNull()
})
it('exposes Yiwu and Dongguan as the fixed top-level warehouses', () => {
expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓'])
expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU')
expect(resolveSmartLoadingWarehouseGroup('东莞仓')?.code).toBe('DONGGUAN')
expect(resolveSmartLoadingWarehouseGroup('其他仓')).toBeNull()
})
})
@@ -0,0 +1,91 @@
export type SmartLoadingChannelGroup = {
code: string
name: string
channelCodes: readonly string[]
}
export type SmartLoadingWarehouseGroup = {
code: string
name: string
nameKeyword: string
}
export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup[] = [
{ code: 'YIWU', name: '义乌仓', nameKeyword: '义乌' },
{ code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' },
] as const
export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] = [
{
code: 'MATSON_CLX',
name: '美森正班',
channelCodes: ['MATSON_CLX_TRUCK', 'MATSON_CLX_PARCEL', 'ZBYHK', 'ZBYHWK', 'ZBYHP'],
},
{
code: 'MATSON_MAX',
name: '美森加班',
channelCodes: ['MATSON_MAX_TRUCK', 'MATSON_MAX_PARCEL', 'BYHK', 'BYHWK', 'BYHP'],
},
{
code: 'EXX',
name: 'EXX',
channelCodes: ['EXX_TRUCK', 'EXX_RAIL_NY', 'EXXHK', 'EXXHWK', 'NYEXX'],
},
{
code: 'HEDE',
name: '合德',
channelCodes: ['HEDE_TRUCK', 'HEDE_PARCEL', 'HDHK', 'HDHWK', 'HDHP'],
},
{
code: 'OA_LA',
name: '洛杉矶普船',
channelCodes: ['OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_PARCEL', 'PCHK', 'PCHWK', 'PCKK', 'PCHP'],
},
{
code: 'OA_CHICAGO',
name: '芝加哥普船',
channelCodes: ['OA_CHICAGO_RAIL', 'CPHT'],
},
{
code: 'OA_HOUSTON',
name: '休斯顿普船',
channelCodes: ['OA_HOUSTON_TRUCK', 'HPHK'],
},
{
code: 'OA_SAVANNAH',
name: '萨凡纳普船',
channelCodes: ['OA_SAVANNAH_TRUCK', 'SPHK'],
},
{
code: 'OA_NEW_YORK_TRUCK',
name: '纽约普船海卡',
channelCodes: ['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'],
},
] as const
export function resolveSmartLoadingChannelGroup(channelCode?: string | null, channelName?: string | null) {
const normalizedCode = String(channelCode || '').trim().toUpperCase()
if (normalizedCode) {
const codeMatch = SMART_LOADING_CHANNEL_GROUPS.find((group) => group.channelCodes.includes(normalizedCode))
if (codeMatch) return codeMatch
}
const normalizedName = String(channelName || '').replace(/\s+/g, '')
if (!normalizedName) return null
if (normalizedName.includes('美森正班')) return SMART_LOADING_CHANNEL_GROUPS[0]
if (normalizedName.includes('美森加班')) return SMART_LOADING_CHANNEL_GROUPS[1]
if (normalizedName.toUpperCase().includes('EXX')) return SMART_LOADING_CHANNEL_GROUPS[2]
if (normalizedName.includes('合德')) return SMART_LOADING_CHANNEL_GROUPS[3]
if (normalizedName.includes('洛杉矶普船') || normalizedName === '普船海派') return SMART_LOADING_CHANNEL_GROUPS[4]
if (normalizedName.includes('芝加哥普船')) return SMART_LOADING_CHANNEL_GROUPS[5]
if (normalizedName.includes('休斯顿普船')) return SMART_LOADING_CHANNEL_GROUPS[6]
if (normalizedName.includes('萨凡纳普船')) return SMART_LOADING_CHANNEL_GROUPS[7]
if (normalizedName.includes('纽约普船')) return SMART_LOADING_CHANNEL_GROUPS[8]
return null
}
export function resolveSmartLoadingWarehouseGroup(warehouseName?: string | null) {
const normalizedName = String(warehouseName || '').replace(/\s+/g, '')
if (!normalizedName) return null
return SMART_LOADING_WAREHOUSE_GROUPS.find((group) => normalizedName.includes(group.nameKeyword)) || null
}
@@ -168,6 +168,13 @@ describe('CustomersView', () => {
vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false)
})
it('waits for the client workspace to confirm impersonation session persistence', () => {
expect(customersSource).toContain("searchParams.set('impersonationOrigin', window.location.origin)")
expect(customersSource).toContain("event.data?.type !== 'impersonation-complete'")
expect(customersSource).toContain("event.data?.type === 'impersonation-failed'")
expect(customersSource).toContain("ElMessage.success('客户工作台登录成功')")
})
it('searches the customer list by customer name only', () => {
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
+30 -11
View File
@@ -777,12 +777,19 @@ async function submitImpersonation() {
impersonationError.value = getApiErrorDetails(new Error('未配置客户工作台地址'), '暂未配置客户工作台地址,无法安全跳转登录')
return
}
let targetOrigin: string
try { targetOrigin = new URL(clientUrl, window.location.origin).origin } catch {
let targetUrl: URL
try {
targetUrl = new URL(clientUrl, window.location.origin)
targetUrl.pathname = `${targetUrl.pathname.replace(/\/+$/, '')}/login`
targetUrl.search = ''
targetUrl.hash = ''
targetUrl.searchParams.set('impersonationOrigin', window.location.origin)
} catch {
impersonationError.value = getApiErrorDetails(new Error('客户工作台地址无效'), '客户工作台地址配置无效,无法安全跳转登录')
return
}
const popup = window.open(clientUrl, '_blank')
const targetOrigin = targetUrl.origin
const popup = window.open(targetUrl.toString(), '_blank')
if (!popup) {
impersonationError.value = getApiErrorDetails(new Error('浏览器拦截了新窗口'), '浏览器拦截了客户工作台窗口,请允许弹窗后重试')
return
@@ -792,27 +799,39 @@ async function submitImpersonation() {
let handoffPending = false
try {
const ticket = await createPlatformTenantImpersonation(impersonationTenant.value.id, { reason: impersonationForm.reason.trim() })
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client' || event.data?.type !== 'impersonation-ready') return
popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin)
let ticketSent = false
const onMessage = (event: MessageEvent) => {
if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client') return
if (event.data?.type === 'impersonation-ready' && !ticketSent) {
ticketSent = true
popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin)
return
}
if (event.data?.type === 'impersonation-failed') {
cleanup()
impersonationSubmitting.value = false
impersonationError.value = getApiErrorDetails(new Error('客户工作台未写入代登录会话'), '客户工作台登录写入失败,请关闭窗口后重试')
return
}
if (event.data?.type !== 'impersonation-complete') return
cleanup()
impersonationSubmitting.value = false
ElMessage.success('客户工作台已打开')
ElMessage.success('客户工作台登录成功')
impersonationDialogOpen.value = false
}
const timeout = window.setTimeout(() => {
cleanup()
impersonationSubmitting.value = false
impersonationError.value = getApiErrorDetails(new Error('客户工作台未完成安全握手'), '客户工作台未完成安全握手,请关闭窗口后重试')
}, 10000)
impersonationError.value = getApiErrorDetails(new Error('客户工作台未确认会话写入'), '客户工作台未确认登录成功,请关闭窗口后重试')
}, 15000)
const cleanup = () => {
window.clearTimeout(timeout)
window.removeEventListener('message', onReady)
window.removeEventListener('message', onMessage)
if (impersonationCleanup === cleanup) impersonationCleanup = null
}
impersonationCleanup?.()
impersonationCleanup = cleanup
window.addEventListener('message', onReady)
window.addEventListener('message', onMessage)
handoffPending = true
} catch (error) {
popup.close()
@@ -28,6 +28,9 @@ export interface PlatformExportTask {
contentType: string | null
sizeBytes: number | null
failureMessage: string | null
attemptCount: number
maxAttempts: number
nextRetryAt: string | null
requestedBy: string
requestedUsername: string | null
requestedAt: string
@@ -30,7 +30,8 @@ const task: PlatformExportTask = {
id: '9', taskNo: 'EXP9', exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表',
status: 'SUCCEEDED', totalRows: 12, processedRows: 12, fileName: '客户列表.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', sizeBytes: 2048,
failureMessage: null, requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00',
failureMessage: null, attemptCount: 1, maxAttempts: 3, nextRetryAt: null,
requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00',
startedAt: '2026-08-03T10:00:01', completedAt: '2026-08-03T10:00:02', expiresAt: '2026-08-10T10:00:02',
}
@@ -85,7 +86,7 @@ describe('DownloadCenterView', () => {
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:export')
})
it('shows retry only for failed tasks with the source export permission', async () => {
it('shows retry for failed tasks with the source export permission', async () => {
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [{ ...task, status: 'FAILED', failureMessage: 'IAM timeout' }], page: 1, size: 20, total: 1, pages: 1 })
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
@@ -102,6 +103,27 @@ describe('DownloadCenterView', () => {
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('shows waiting retry and allows an immediate retry during automatic backoff', async () => {
vi.mocked(listPlatformExportTasks).mockResolvedValue({
records: [{ ...task, status: 'PENDING', processedRows: 0, failureMessage: '头程服务查询失败', attemptCount: 2, nextRetryAt: '2026-09-03T10:34:28' }],
page: 1, size: 20, total: 1, pages: 1,
})
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:download:list', 'platform:customer:export']
useAccessPreviewStore().initializeAfterLogin()
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('等待重试')
const retry = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((item) => item.textContent?.trim() === '立即重试')
retry?.click()
await flushPromises()
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('labels and retries Finance tasks with their exact source export permission', async () => {
const financeTask = {
...task,
@@ -222,6 +222,13 @@ function statusMeta(status: ExportTaskStatus) {
return values[status] ?? { label: status || '未知状态', type: 'info' as const }
}
function taskStatusMeta(task: PlatformExportTask) {
if (task.status === 'PENDING' && task.attemptCount > 0) {
return { label: '等待重试', type: 'warning' as const }
}
return statusMeta(task.status)
}
function formatDate(value: string | null) {
if (!value) return '—'
const date = new Date(value)
@@ -237,7 +244,12 @@ function formatSize(bytes: number | null) {
function canRetry(task: PlatformExportTask) {
const permission = exportTypeMeta[task.exportType]?.retryPermission
return task.status === 'FAILED' && permission !== undefined && access.can(permission)
const retryableStatus = task.status === 'FAILED' || (task.status === 'PENDING' && task.attemptCount > 0)
return retryableStatus && permission !== undefined && access.can(permission)
}
function retryLabel(task: PlatformExportTask) {
return task.status === 'PENDING' ? '立即重试' : '重试'
}
watch([page, pageSize], () => { void loadTasks() })
@@ -275,10 +287,10 @@ onBeforeUnmount(() => {
<template #actions="{ row }"><div class="table-actions">
<el-button v-if="(row as PlatformExportTask)?.id" link type="primary" @click="openDetail(row as PlatformExportTask)"><Eye :size="15" />详情</el-button>
<el-button v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" link type="primary" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)"><Download :size="15" />下载</el-button>
<el-button v-if="canRetry(row as PlatformExportTask)" link type="primary" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)"><RotateCcw :size="15" />重试</el-button>
<el-button v-if="canRetry(row as PlatformExportTask)" link type="primary" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)"><RotateCcw :size="15" />{{ retryLabel(row as PlatformExportTask) }}</el-button>
</div></template>
<template #type="{ row }">{{ typeLabel((row as PlatformExportTask).exportType) }}</template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformExportTask).status).type" effect="light">{{ statusMeta((row as PlatformExportTask).status).label }}</el-tag></template>
<template #status="{ row }"><el-tag :type="taskStatusMeta(row as PlatformExportTask).type" effect="light">{{ taskStatusMeta(row as PlatformExportTask).label }}</el-tag></template>
<template #progress="{ row }"><span>{{ (row as PlatformExportTask).processedRows }}<template v-if="(row as PlatformExportTask).totalRows !== null"> / {{ (row as PlatformExportTask).totalRows }}</template></span><small v-if="(row as PlatformExportTask).sizeBytes !== null">{{ formatSize((row as PlatformExportTask).sizeBytes) }}</small></template>
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || (row as PlatformExportTask).requestedBy }}</template>
<template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template>
@@ -309,7 +321,7 @@ onBeforeUnmount(() => {
<h3>{{ detailTask.taskName }}</h3>
<p>{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
</div>
<el-tag :type="statusMeta(detailTask.status).type" effect="light">{{ statusMeta(detailTask.status).label }}</el-tag>
<el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag>
</div>
<dl class="task-detail-grid">
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo }}</dd></div>
@@ -317,6 +329,8 @@ onBeforeUnmount(() => {
<div><dt>申请时间</dt><dd>{{ formatDate(detailTask.requestedAt) }}</dd></div>
<div><dt>开始时间</dt><dd>{{ formatDate(detailTask.startedAt) }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatDate(detailTask.completedAt) }}</dd></div>
<div><dt>执行次数</dt><dd>{{ detailTask.attemptCount }} / {{ detailTask.maxAttempts }}</dd></div>
<div><dt>下次重试</dt><dd>{{ detailTask.status === 'PENDING' && detailTask.attemptCount > 0 ? formatDate(detailTask.nextRetryAt) : '—' }}</dd></div>
<div><dt>过期时间</dt><dd>{{ formatDate(detailTask.expiresAt) }}</dd></div>
<div><dt>处理数据</dt><dd>{{ detailTask.processedRows }}<template v-if="detailTask.totalRows !== null"> / {{ detailTask.totalRows }}</template> 行</dd></div>
<div><dt>文件大小</dt><dd>{{ formatSize(detailTask.sizeBytes) || '—' }}</dd></div>
@@ -330,6 +344,11 @@ onBeforeUnmount(() => {
<template #footer="{ close }">
<div class="app-dialog__footer">
<el-button @click="close">关闭</el-button>
<el-button
v-if="detailTask && canRetry(detailTask)"
:loading="retryingId === detailTask.id"
@click="retryTask(detailTask)"
>{{ retryLabel(detailTask) }}</el-button>
<el-button
type="primary"
:loading="downloadingId === detailTask?.id"
+2 -1
View File
@@ -15,7 +15,8 @@
## 费用付款与凭证快速入口
- 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;柜级公共费用在同一面板按真实客户与订单分摊,入仓码按事实选填;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。
- 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;柜级公共费用在同一面板按服务端返回的真实客户与订单分摊,入仓 ID/入仓码同时选填或同时为空;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。
- 柜级分摊请求的各行金额之和必须精确等于公共费用总额;创建后使用 `allocationGroupId` 整组审核、驳回、调整或作废,前端不得调用单行接口造成部分成功,也不得提交柜外客户、订单或入仓归属。
- `DIRECT_PAID` 必须提交已付时间、付款方式,以及付款流水号或至少一个付款凭证;凭证由费用 API 调用 `/platform/finance/v1/vouchers` 上传为 `FINANCE_VOUCHER`,并绑定当前客户。
- 后端事实入口为 Firstleg `FeePaymentSnapshotValidator`、入库/柜次费用服务,文件元数据归 File;Finance 只消费已发布的费用来源快照,不在前端或 Finance 中重建付款事实。