style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
@@ -14,6 +15,7 @@ const api = vi.hoisted(() => ({
downloadSource: vi.fn(),
downloadReceipt: vi.fn(),
downloadFinal: vi.fn(),
recordResult: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
@@ -27,6 +29,7 @@ vi.mock('../api/platformContainerCustoms', () => ({
submitContainerCustomsToBroker: vi.fn(),
sendContainerCustomsToBroker: api.sendToBroker,
getDeclarationBatches: api.getDeclarationBatches,
recordClearanceResult: api.recordResult,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -77,6 +80,51 @@ describe('ContainerCustomsPanel', () => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => {
const batch: DeclarationBatch = {
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3,
customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null,
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [],
}
api.getDeclarationBatches.mockResolvedValue([batch])
api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: {
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
} }).setupState
state.clearanceResultType = 'OTHER'
await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001')
expect(dialog.props('lockWhileSubmitting')).toBe(false)
dialog.vm.$emit('confirm')
await flushPromises()
expect(api.recordResult).not.toHaveBeenCalled()
state.clearanceRemark = ' 已核实海关通知 '
dialog.vm.$emit('confirm')
await flushPromises()
expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知')
expect(dialog.props('modelValue')).toBe(false)
wrapper.unmount()
})
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
const wrapper = await render('READY_TO_START')
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
expect(api.start).not.toHaveBeenCalled()
confirm.mockRestore()
wrapper.unmount()
})
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
const reasons: Array<[ClearanceResultType, string]> = [
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
@@ -7,6 +7,8 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import {
completeAllBuyContainerCustoms,
createDeclarationBatch,
@@ -113,13 +115,18 @@ async function load() {
finally { loading.value = false }
}
function customsDialogTitle(action: string, batch?: DeclarationBatch | null) {
const code = batch?.batchNo?.trim()
return code ? `${action} · ${code}` : containerDialogTitle(action, customs.value)
}
function declarationStatusLabel(value: DeclarationBatch['status']) {
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
}
async function createFormalDeclaration() {
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
@@ -128,7 +135,7 @@ async function createFormalDeclaration() {
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
const answer = await ElMessageBox.prompt('请输入报关单号', customsDialogTitle('提交正式申报', batch), { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
actionLoading.value = true; actionError.value = null
await submitDeclarationBatch(batch, answer.value)
await load(); ElMessage.success('正式申报已提交')
@@ -163,7 +170,7 @@ async function recordFormalResult() {
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
try {
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
const answer = await ElMessageBox.prompt('请输入处理说明', customsDialogTitle('处理海关结果', batch), { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
actionLoading.value = true; actionError.value = null
await resolveClearanceResult(result, batch.version, answer.value)
await load(); ElMessage.success('海关结果已处理')
@@ -176,7 +183,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
@@ -242,7 +249,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
async function submitToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
@@ -251,7 +258,7 @@ async function submitToBroker() {
async function sendToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
@@ -260,7 +267,7 @@ async function sendToBroker() {
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', customsDialogTitle('完成全买单柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
catch { return }
actionLoading.value = true; actionError.value = null
try {
@@ -346,10 +353,10 @@ onMounted(load)
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
<el-empty v-else-if="canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
<el-dialog v-model="clearanceDialogVisible" class="admin-density-dialog" title="标记海关异常/结果" width="460px" :close-on-click-modal="false">
<el-form label-position="top" class="clearance-result-form">
<AppDialog v-model="clearanceDialogVisible" class="admin-density-dialog" :title="customsDialogTitle('标记海关异常/结果', clearanceBatch)" width="460px" :close-on-click-modal="false" :lock-while-submitting="false" :confirm-loading="actionLoading" confirm-text="确认标记" @confirm="recordFormalResult">
<el-form label-position="top" class="density-form density-form--flush-end">
<el-form-item label="选择理由" required>
<el-select v-model="clearanceResultType" class="clearance-result-select" placeholder="请选择理由">
<el-select v-model="clearanceResultType" placeholder="请选择理由">
<el-option v-for="option in clearanceResultOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
@@ -357,11 +364,10 @@ onMounted(load)
<el-input v-model="clearanceRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="clearanceResultType === 'OTHER' ? '请填写具体情况' : '可填写短信内容或人工处理说明(可选)'" />
</el-form-item>
</el-form>
<template #footer><el-button @click="clearanceDialogVisible = false">取消</el-button><el-button type="primary" :loading="actionLoading" @click="recordFormalResult">确认标记</el-button></template>
</el-dialog>
</AppDialog>
</section>
</template>
<style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}
</style>
@@ -1,25 +1,26 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import ElementPlus, { ElFormItem, ElSelect } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue'
const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download }))
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] }))
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [{ id: '21', supplierName: '供应商甲', category: 'TERMINAL_DRAYAGE' }] }))
vi.mock('../api/overseasContainerFees', () => ({
listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }],
createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(),
}))
async function setup(readonly = false) {
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: {
async function setup(readonly = false, realDialog = false, containerNo: string | null = 'CONT-99') {
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', cabinetNo: 'CAB-99', containerNo, readonly }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : {
AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '<section v-if="modelValue"><h3>{{ title }}</h3><slot /><button :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>' },
} } })
await flushPromises()
return wrapper
}
type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean }
type FormState = { form: { customerId: string; settlementMode: string; direction: string; amount: number | null; businessDate: string; supplierId: string; paidAt: string; paymentReferenceNo: string }; dialogOpen: boolean }
async function upload(wrapper: Awaited<ReturnType<typeof setup>>, name: string) {
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] })
@@ -37,6 +38,60 @@ describe('尾程付款凭证', () => {
mocks.download.mockResolvedValue(new Blob(['image']))
})
it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => {
const wrapper = await setup(false, true)
await wrapper.get('button').trigger('click')
await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))!
expect(dialog.props('title')).toBe('新增尾程费用 · CAB-99')
const labels = () => dialog.findAllComponents(ElFormItem).map(item => item.props('label'))
expect(labels()).toHaveLength(7)
state.form.settlementMode = 'DIRECT_PAID'
state.form.amount = 120.25
state.form.supplierId = '21'
state.form.businessDate = '2026-10-19'
state.form.paidAt = '2026-10-19T09:00:00'
state.form.paymentReferenceNo = ' PAY-001 '
await flushPromises()
expect(labels()).toHaveLength(11)
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
expect(state.form.amount).toBe(120.25)
await dialog.get('[aria-label="退出全屏"]').trigger('click')
expect(state.form.paymentReferenceNo).toBe(' PAY-001 ')
dialog.vm.$emit('confirm')
await flushPromises()
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({
customerId: '11', containerBookingId: '99', containerNo: 'CONT-99', businessNo: 'CONT-99',
amount: 120.25, supplierId: '21', settlementMode: 'DIRECT_PAID',
paidAt: '2026-10-19T09:00:00', paymentReferenceNo: 'PAY-001', currency: 'CNY',
}), expect.stringContaining('container-fee-create-'))
wrapper.unmount()
})
it('调整的所属客户显示只读名称,应收费用隐藏供应商和付款字段', async () => {
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', sourceVersion: 7, customerId: '11', customerName: '客户甲', feeItemCode: 'PORT_FEE', amount: 10, businessDate: '2026-10-19', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', paymentVoucherFileIds: [] }])
const wrapper = await setup(false, true)
await wrapper.findAll('button').find(button => button.text() === '调整')!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整尾程费用'))!
const fields = dialog.findAllComponents(ElFormItem)
expect(fields.map(item => item.props('label'))).toEqual(['客户', '费用项目', '费用方向', '业务日期', '金额(CNY)'])
const customer = fields[0]!
expect(customer.text()).toContain('客户甲')
expect(customer.findComponent(ElSelect).exists()).toBe(false)
wrapper.unmount()
})
it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => {
const wrapper = await setup(false, true, null)
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('尚未补录真实集装箱号')
expect(mocks.create).not.toHaveBeenCalled()
wrapper.unmount()
})
it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => {
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }])
const wrapper = await setup()
@@ -7,11 +7,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; cabinetNo?: string | null; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
const access = useAccessPreviewStore()
const rows = ref<OverseasContainerFeeSource[]>([])
const customers = ref<OverseasContainerCustomer[]>([])
@@ -146,17 +147,17 @@ async function uploadVoucher(event: Event) {
}
}
async function submit(row: OverseasContainerFeeSource) {
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { customClass: 'admin-density-confirm', type: 'warning' })
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, containerDialogTitle('提交费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
}
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
let reason: string | null = null
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { customClass: 'admin-density-confirm', type: 'warning' })
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', containerDialogTitle('驳回费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, containerDialogTitle('审核费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
}
async function voidFee(row: OverseasContainerFeeSource) {
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
const result = await ElMessageBox.prompt('请输入作废原因', containerDialogTitle('作废费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态')
}
onMounted(load)
@@ -182,13 +183,24 @@ onMounted(load)
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<el-form class="density-form" label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form" label-position="left">
<table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
<tr v-if="form.direction === 'PAYABLE'"><td :colspan="form.settlementMode === 'DIRECT_PAID' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item></td><td v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label-position="left" label-width="110px" label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item></td></tr>
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td :colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></td></tr>
</tbody></table>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
<AppDialog class="admin-density-dialog last-leg-voucher-dialog" v-model="previewOpen" :title="containerDialogTitle('付款凭证', props)" :fullscreenable="Boolean(previewUrl) && previewIsImage" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
<p v-if="previewLoading">正在读取付款凭证…</p>
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
<div v-else-if="previewUrl" class="density-dialog-section voucher-preview" data-tone="source"><p class="density-dialog-note">{{ previewName }}</p><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
</AppDialog>
</div>
</template>
@@ -201,10 +213,13 @@ onMounted(load)
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); min-width: 0; }
.voucher-item { display: flex; gap: var(--admin-space-3); max-width: 100%; }
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
.voucher-preview { display: grid; gap: var(--admin-space-5); }
.voucher-preview { display: grid; gap: var(--admin-space-5); min-width: 0; overflow-wrap: anywhere; }
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
.row-actions { white-space: nowrap; }
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
.fee-editor-table { table-layout: fixed; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-editor-table, .fee-editor-table tbody, .fee-editor-table tr, .fee-editor-table td { display: block; width: 100%; } }
</style>
<style>
.last-leg-voucher-dialog.is-fullscreen .voucher-preview img { max-height: 80vh; }
</style>
@@ -0,0 +1,43 @@
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import type { PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
import { createContainerSimulation } from '../api/platformContainerLoading'
import AppDialog from '@/shared/components/AppDialog.vue'
import ContainerSimulationCreate from './ContainerSimulationCreate.vue'
// Node's supported CJS export keeps the real form validator's constructor intact.
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ createContainerSimulation: vi.fn() }))
const warehouse: PlatformWarehouseSummaryResource = { id: '8', tenantId: 'platform', warehouseCode: 'CN-01', internalCode: null, warehouseName: '测试国内仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
enableAutoUnmount(afterEach)
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }]) })
afterEach(() => { document.body.innerHTML = '' })
describe('ContainerSimulationCreate opted-in form', () => {
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
const wrapper = mount(ContainerSimulationCreate, { props: { dense, loadWarehouseOptions: async () => [warehouse] }, global: { plugins: [ElementPlus] }, attachTo: document.body })
await wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createContainerSimulation).not.toHaveBeenCalled()
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
expect(fields).toHaveLength(4)
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
await flushPromises()
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
await flushPromises()
const specification = fields.find(field => field.props('label') === '统一规格')!
expect(specification.find('input').exists()).toBe(!dense)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
expect(wrapper.emitted('created')).toHaveLength(1)
})
})
@@ -96,13 +96,8 @@ async function saveSimulation() {
<template>
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item>
</el-form>
<el-alert title="模拟柜用于先排货,创建后自动生成柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<section :class="{ 'density-dialog-section': dense }" data-tone="work"> <el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" :label-position="dense ? 'left' : 'top'" :label-width="dense ? '120px' : undefined" class="simulation-form" :class="{ 'density-form': dense }"><template v-if="dense"><table :class="{ 'density-table': dense, 'density-table--editable': dense }" aria-label="模拟柜资料"><tbody><tr><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item></td><td><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="统一规格"><span>{{ `${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg` }}</span><small :class="{ 'density-dialog-note': dense }">{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></td></tr></tbody></table></template><template v-else><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></template></el-form></section>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
@@ -5,6 +5,7 @@ import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import type { ContainerBookingResource } from '../types'
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
@@ -35,6 +36,10 @@ describe('OceanBookingLinkDialog', () => {
it('loads matching released-booking requirements when opened', async () => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findComponent(AppDialog).props('title')).toBe('关联已放舱订舱 · CAB-1')
const identity = wrapper.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('label') === '柜次号')!
expect(identity.text()).toContain('CAB-1')
expect(identity.find('input').exists()).toBe(false)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
wrapper.unmount()
@@ -49,6 +54,9 @@ describe('OceanBookingLinkDialog', () => {
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
vm.candidateKey = '101:202'
vm.containerNo = input
await wrapper.findComponent(AppDialog).find('button[aria-label="展开全屏"]').trigger('click')
expect(vm.containerNo).toBe(input)
expect(linkContainerOceanBooking).not.toHaveBeenCalled()
await vm.save()
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
@@ -8,6 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import { containerDialogTitle } from '../containerDialogTitle'
import type { ContainerBookingResource } from '../types'
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
@@ -98,18 +99,16 @@ defineExpose({ candidates, candidateKey, containerNo, save })
</script>
<template>
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
<!-- <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" /> -->
<el-form class="density-form" label-position="top">
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
</el-form>
<!-- <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> -->
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="containerDialogTitle('关联已放舱订舱', container)" fullscreenable width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form" label-position="left" label-width="160px">
<table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
</tbody></table>
</el-form>
</section>
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.dialog-hint { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
</style>
@@ -22,6 +22,7 @@ describe('SmartLoadingDialog', () => {
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(document.querySelector('.smart-loading-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
@@ -45,6 +46,7 @@ describe('SmartLoadingDialog', () => {
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
expect(source).toContain('height: 94vh; max-height: 94vh;')
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
expect(source).toContain('.smart-loading-dialog.app-dialog.admin-density-dialog .el-dialog__body')
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
@@ -8,7 +9,7 @@ const open = defineModel<boolean>({ default: false })
<template>
<AppDialog
v-model="open"
class="smart-loading-dialog"
class="smart-loading-dialog admin-density-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
fullscreenable
@@ -23,7 +24,7 @@ const open = defineModel<boolean>({ default: false })
<style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
:global(.smart-loading-dialog.app-dialog.admin-density-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
@media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { containerDialogTitle } from './containerDialogTitle'
describe('containerDialogTitle business identity', () => {
it('uses the stable cabinet number when both business identifiers exist', () => {
expect(containerDialogTitle('编辑货柜资料', { cabinetNo: ' CB-001 ', containerNo: 'MSCU1234567' })).toBe('编辑货柜资料 · CB-001')
})
it('uses a real container number if the cabinet number is missing', () => {
expect(containerDialogTitle('确认还柜', { cabinetNo: ' ', containerNo: ' MSCU1234567 ' })).toBe('确认还柜 · MSCU1234567')
})
it('never substitutes an internal ID for a missing business identifier', () => {
const container = { id: 'private-container-id', cabinetNo: null, containerNo: ' ' }
expect(containerDialogTitle('直送卸货登记', container)).toBe('直送卸货登记')
expect(containerDialogTitle('直送卸货登记', null)).toBe('直送卸货登记')
})
})
@@ -0,0 +1,6 @@
type ContainerIdentity = { cabinetNo?: string | null; containerNo?: string | null }
export function containerDialogTitle(action: string, container?: ContainerIdentity | null) {
const code = container?.cabinetNo?.trim() || container?.containerNo?.trim()
return code ? `${action} · ${code}` : action
}
@@ -93,14 +93,16 @@ describe('ContainerDetailView empty return', () => {
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
}
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
await flushPromises(); await dialog.find('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent(ElDatePicker).props('modelValue')).toBe('2026-10-16T01:00:00')
dialog.vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
expect(dialog.text()).not.toContain('9007199254740999')
@@ -114,24 +116,46 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
expect(dialog.findAll('input, textarea')).toHaveLength(0)
expect(dialog.text()).toContain('POD-001')
expect(dialog.text()).toContain('America/Los_Angeles')
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
})
it('does not mislabel a cabinet number as a real container number or an unnamed fleet as unassigned', async () => {
routeState.query = { action: 'pickup' }
const withoutNumber = { ...(await getContainer('9001')), containerNo: '' }
const unnamedFleet = { ...(await getTerminalDrayage('9001')), containerNo: '', fleetSupplierId: 'private-supplier-id', fleetSupplierName: '' }
vi.mocked(getContainer).mockResolvedValue(withoutNumber)
vi.mocked(getTerminalDrayage).mockResolvedValue(unnamedFleet)
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
const fields = dialog.findAllComponents(ElFormItem)
const number = fields.find(field => field.props('label') === '真实集装箱号')!
expect(number.text()).toBe('真实集装箱号—')
const fleet = fields.find(field => field.props('label') === '当前车队')!
expect(fleet.text()).toBe('当前车队—')
expect(fleet.text()).not.toContain('private-supplier-id')
expect(fleet.text()).not.toContain('尚未安排')
})
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
routeState.query = { action: 'pickup' }
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
const fleetButton = dialog.findAll('button').find(button => button.text() === '保存码头车队')!
expect(fleetButton.element.closest('.el-dialog__footer')).not.toBeNull()
await fleetButton.trigger('click'); await flushPromises()
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
@@ -143,7 +167,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
@@ -155,7 +179,7 @@ describe('ContainerDetailView empty return', () => {
routeState.query = { action: 'pickup' }
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
@@ -211,7 +235,7 @@ describe('ContainerDetailView empty return', () => {
expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises()
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('确认还柜'))!
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); await flushPromises()
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
confirm.click()
@@ -75,7 +75,10 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
const firstLegFees = source.match(/<ContainerFeesPanel\b[^>]*\/>/)?.[0] || ''
for (const attribute of ['title="国内段费用(头程)"', ':plan-id="overview.loadingPlanId"', ':container-booking-id="detail.id"', ':cabinet-no="detail.cabinetNo"', ':container-no="detail.containerNo"', ':customers="feeCustomers"', ':business-references="feeBusinessReferences"']) {
expect(firstLegFees).toContain(attribute)
}
expect(source).not.toContain(':readonly="isOverseas"')
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
expect(source).toContain('title="目的港及海外段费用(尾程)"')
@@ -25,6 +25,7 @@ import {
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
@@ -172,7 +173,7 @@ async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
async function leaveTerminal() {
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', containerDialogTitle('未保存的安排', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
onBeforeRouteLeave(leaveTerminal)
@@ -186,7 +187,7 @@ const directPending = ref<{ containerId: string; requirementKey: string; body: D
async function leaveDirect() {
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
onBeforeRouteLeave(leaveDirect)
onBeforeRouteUpdate(async (to, from) => {
@@ -287,7 +288,7 @@ const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEm
async function leaveEmptyReturn() {
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
onBeforeRouteLeave(leaveEmptyReturn)
@@ -486,7 +487,7 @@ async function confirmDeparture() {
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
@@ -691,7 +692,7 @@ onMounted(async () => {
<h2 id="container-voyage-heading">船名航次信息</h2>
<ContainerPortSchedulePanel :container="detail" @saved="load" />
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
@@ -736,49 +737,45 @@ onMounted(async () => {
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
</section>
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" />
</section>
</template>
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="admin-density-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<p class="density-dialog-note">按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p>
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
<p v-if="directLoading">正在读取实际装柜来源…</p>
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></section>
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></td></tr></tbody></table></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
<AppDialog class="admin-density-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
</el-form>
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
</template>
</AppDialog>
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">取消</el-button><template v-if="terminal"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button><el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button></template></div></template></AppDialog>
</AdminPage>
</template>
<style scoped>
.container-workflow-table { table-layout: fixed; }
.direct-remark { white-space: pre-wrap; }
.loading-list-heading { margin-bottom: var(--admin-space-5); }
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
@@ -133,7 +133,7 @@ describe('ContainersView', () => {
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
await returnDialog.find('textarea').setValue('还柜凭证已核对')
const callsBefore = vi.mocked(listContainers).mock.calls.length
@@ -176,7 +176,7 @@ describe('ContainersView', () => {
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title').startsWith('确认还柜') && dialog.props('modelValue'))).toBe(false)
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
@@ -200,7 +200,7 @@ describe('ContainersView', () => {
const open = async () => {
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
await flushPromises()
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
}
const first = await open()
first.vm.$emit('update:modelValue', false)
@@ -270,7 +270,19 @@ describe('ContainersView', () => {
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
const editor = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('编辑货柜资料'))!
expect(editor.props('title')).toBe('编辑货柜资料 · CB-1')
const editorFields = editor.findAllComponents({ name: 'ElFormItem' })
expect(editorFields).toHaveLength(17)
for (const label of ['真实集装箱号', '柜次号', '货柜容量(m³)', '重量上限(kg)']) expect(editorFields.find(field => field.props('label') === label)!.find('input').exists()).toBe(false)
const vessel = editorFields.find(field => field.props('label') === '船名')!.findComponent({ name: 'ElInput' })
vessel.vm.$emit('update:modelValue', '编辑中的船名')
await flushPromises()
await editor.find('button[aria-label="展开全屏"]').trigger('click')
expect(editor.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(vessel.props('modelValue')).toBe('编辑中的船名')
expect(updateContainer).not.toHaveBeenCalled()
editor.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
@@ -417,9 +429,9 @@ describe('ContainersView', () => {
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('width="min(1080px, calc(100vw - 28px))"')
expect(source).toContain('admin-density-dialog')
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(source).toContain('class="density-table density-table--editable container-editor-table"')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
@@ -588,7 +600,7 @@ describe('ContainersView', () => {
expect(link).toBeDefined()
link.click()
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))?.props('modelValue')).toBe(true)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
} finally {
wrapper.unmount()
@@ -617,8 +629,8 @@ describe('ContainersView', () => {
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(simulationSource).toContain('selectSimulationContainerType')
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
expect(simulationSource).toContain('创建后自动生成柜次号')
expect(source).toContain("{{ form.cabinetNo || '—' }}")
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
expect(source).not.toContain('openCreate')
@@ -725,7 +737,7 @@ describe('ContainersView', () => {
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
.find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))!.vm.$emit('update:modelValue', false)
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
import { containerDialogTitle } from '../containerDialogTitle'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
@@ -147,7 +148,7 @@ async function retryEmail(mail: TerminalDrayageEmail) {
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
retryingEmail.value = mail.id
try {
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', containerDialogTitle('重试邮件', emailTarget.value), { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await retryTerminalDrayageEmail(target.id, mail.id)
ElMessage.success('邮件已重新加入发送队列')
await openEmailHistory(target)
@@ -197,7 +198,7 @@ const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:termi
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const editorTitle = computed(() => form.id ? containerDialogTitle('编辑货柜资料', form) : '新增')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
interface PortOption { value: string; code: string | null; name: string | null }
@@ -775,59 +776,53 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(1080px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
</el-form>
<p class="density-dialog-note">货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</p>
<section v-if="!warehouseError" class="density-dialog-section" data-tone="work">
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form">
<table class="density-table density-table--editable container-editor-table" aria-label="货柜基础资料"><tbody>
<tr><td><el-form-item label="真实集装箱号"><span>{{ form.containerNo || '待补录' }}</span></el-form-item></td><td><el-form-item label="柜次号"><span>{{ form.cabinetNo || '—' }}</span></el-form-item></td><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
<tr><td><el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item></td><td><el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td><td><el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="货柜容量(m³)"><span>{{ form.capacityCbm == null ? '—' : `${form.capacityCbm} m³` }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="重量上限(kg)"><span>{{ form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg` }}</span></el-form-item></td><td><el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item></td></tr>
<tr><td><el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item></td><td colspan="2"><el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item></td></tr>
</tbody></table>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="containerDialogTitle(drayageActionText(drayageAction), drayageTarget)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" :title="containerDialogTitle('车队邮件记录', emailTarget)" fullscreenable width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p class="density-dialog-note">集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<section v-else class="density-dialog-section" data-tone="source"><DataTable :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
</DataTable>
</DataTable></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" :title="containerDialogTitle('码头拖车记录', drayageTarget)" fullscreenable width="min(980px, calc(100vw - 28px))" :show-footer="false">
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
<div v-else class="density-dialog-sections"><section v-if="drayageTarget && drayageFor(drayageTarget.id)" class="density-dialog-section" data-tone="summary"><el-descriptions :column="4" border class="density-descriptions"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions></section><section class="density-dialog-section" data-tone="source"><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></section></div>
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
</style>
@@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
@@ -118,6 +119,49 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
}
describe('GlobalSmartLoadingView', () => {
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
if (unitType === 'SEALED_PALLET') {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType, palletId: '801', palletNo: 'DP-TEST-1', indivisible: true,
groupMembers: [
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户甲', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户甲', availablePackageCount: 9, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
],
}]))
}
vi.mocked(assignSmartLoadingItem).mockResolvedValue(plan)
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(plan)
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
await flushPromises()
const popup = wrapper.getComponent(AppDialog)
expect(popup.get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
if (unitType === 'PACKAGE') {
const input = popup.getComponent(ElInputNumber)
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
expect(input.props('max')).toBe(candidate.availablePackageCount)
input.vm.$emit('update:modelValue', 4)
} else {
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
expect(popup.get('details').text()).toContain('I181:9 箱')
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
}
await popup.get('[aria-label="展开全屏"]').trigger('click')
expect(popup.find('.el-dialog.is-fullscreen').exists()).toBe(true)
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
popup.vm.$emit('confirm'); await flushPromises()
if (unitType === 'PACKAGE') {
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 4, expectedVersion: plan.version })
expect(assignSmartLoadingPallet).not.toHaveBeenCalled()
} else {
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(plan.id, { palletId: '801', expectedVersion: plan.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
}
expect(popup.props('modelValue')).toBe(false)
wrapper.unmount()
})
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
@@ -139,6 +183,7 @@ describe('GlobalSmartLoadingView', () => {
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
@@ -498,8 +543,13 @@ describe('GlobalSmartLoadingView', () => {
await buttons[0].trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
const contextRows = popup.findAll('table[aria-label="全局装柜分配来源"] tr')
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
await popup.get('[aria-label="展开全屏"]').trigger('click')
expect(popup.get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -533,10 +583,12 @@ describe('GlobalSmartLoadingView', () => {
expect(add.classes()).not.toContain('is-disabled')
await add.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
expect(document.body.textContent).toContain('订单渠道:合德海卡')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
const measurements = popup.findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
const channel = popup.findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
;([...document.body.querySelectorAll('button')]
@@ -876,6 +928,7 @@ describe('GlobalSmartLoadingView', () => {
await removeAllButton!.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.any(String), '移除未拣明细 · CB-1', expect.objectContaining({ customClass: 'admin-density-confirm' }))
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
@@ -1066,7 +1119,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('.channel-tags :deep(.el-checkbox-button__inner)')
expect(source).toContain('flex-wrap: nowrap; gap: 6px;')
expect(source).toContain('height: 34px; padding: 0 10px;')
expect(source).toContain('font-size: 13px; line-height: 32px;')
expect(source).toContain('font-size: var(--admin-font-body); line-height: 32px;')
expect(source).not.toContain('class="summary-band"')
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
@@ -65,6 +66,12 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignmentTitle = computed(() => {
const candidate = selectedCandidate.value
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim() || candidate?.orderNo?.trim()
return code ? `${action} · ${code}` : action
})
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
@@ -590,6 +597,10 @@ async function adjust(source: object) {
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
function workbenchDialogTitle(action: string) {
const code = selectedContainer.value?.cabinetNo?.trim() || (hasRealContainerNo(selectedContainer.value) ? selectedContainer.value?.containerNo?.trim() : '')
return code ? `${action} · ${code}` : action
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
@@ -600,8 +611,8 @@ async function remove(source: object) {
: sealedPallet
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
workbenchDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -632,8 +643,8 @@ async function removeAll() {
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
if (!removableItems.length) return
const itemCount = removableItems.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, workbenchDialogTitle('移除未拣明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -676,7 +687,7 @@ onMounted(() => { void load(); void loadCustomers() })
</script>
<template>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<component class="admin-density" :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page">
<div v-if="channelOptions.length" class="channel-summary-row">
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
@@ -743,9 +754,10 @@ onMounted(() => { void load(); void loadCustomers() })
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
<template #actions="{ row }">
<el-button
class="density-action" data-tone="primary"
v-if="canAssign && assignable"
size="small"
type="text"
type="primary"
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
@@ -774,6 +786,7 @@ onMounted(() => { void load(); void loadCustomers() })
type="button"
class="container-option"
:class="{ selected: item.bookingId === selectedContainerId }"
:aria-pressed="item.bookingId === selectedContainerId"
@click="selectContainer(item.bookingId)"
>
<span class="container-option__top">
@@ -825,7 +838,7 @@ onMounted(() => { void load(); void loadCustomers() })
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
@@ -837,28 +850,38 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<table class="density-table assignment-context-table" aria-label="全局装柜分配来源"><tbody>
<tr><th>目标柜次</th><td>{{ displayCabinetNo(selectedContainer) }}</td><th>集装箱号</th><td>{{ displayActualContainerNo(selectedContainer) }}</td></tr>
<tr><th>发货仓</th><td>{{ selectedContainer?.warehouseName || '—' }}</td><th>柜次渠道</th><td>{{ containerChannelGroup(selectedContainer)?.name || '—' }}</td></tr>
<tr><th>订单渠道</th><td>{{ selectedCandidate.channelName || '—' }}</td><th>{{ selectedCandidate.coLoadGroupId ? '同柜组' : '客户' }}</th><td>{{ selectedCandidate.coLoadGroupId ? selectedCandidate.groupNo : selectedCandidate.customerName || '客户待补充' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>托盘号</th><td>{{ selectedCandidate.palletNo || '—' }}</td><th>整托箱数</th><td>整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<template v-else>
<tr><th>入仓码</th><td>{{ selectedCandidate.inboundCode || '—' }}</td><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<tr><th>平均单箱毛重</th><td>{{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }}</td><th>平均单箱体积</th><td>{{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</td></tr>
<tr><td colspan="4"><el-form-item label="本次分配箱数" label-position="left" label-width="128px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
</template>
</tbody></table>
</el-form>
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
</template>
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
</div>
</AppDialog>
</template>
@@ -867,24 +890,26 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.assignment-context-table { table-layout: fixed; }
.assignment-context-table th { width: 120px; }
.global-loading-page { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
.embedded-smart-loading { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.embedded-smart-loading .global-loading-page { width: 100%; min-height: 0; flex: 1; overflow: hidden; }
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tags { display: flex; min-width: 0; align-items: center; flex-wrap: nowrap; gap: 6px; padding: 6px 0; overflow-x: auto; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: 13px; font-weight: 600; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
.channel-tags :deep(.el-checkbox-button) { flex: 0 0 auto; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: 13px; line-height: 32px; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: var(--admin-font-body); line-height: 32px; }
.compact-summary { display: flex; min-height: 46px; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; padding: 5px 8px; white-space: nowrap; }
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
.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; }
.compact-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption); }
.compact-summary strong { color: var(--el-color-primary); font-size: var(--admin-font-section); }
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: nowrap; gap: 8px; }
:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.dispatch-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 2fr) minmax(0, 3fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { height: auto; min-height: 0; flex: 1; }
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
@@ -902,9 +927,9 @@ onMounted(() => { void load(); void loadCustomers() })
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
.container-option { display: grid; width: 100%; min-height: 112px; height: auto; align-content: start; gap: 6px; padding: 14px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.4; text-align: left; cursor: pointer; }
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.container-option__identity { color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
.plan-header { flex-wrap: wrap; }
@@ -914,17 +939,15 @@ onMounted(() => { void load(); void loadCustomers() })
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card strong { font-size: var(--admin-font-title); }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: var(--admin-font-caption); }
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.package-count-summary strong { font-size: 13px; font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; }
@@ -1,5 +1,6 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
@@ -75,7 +76,9 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).not.toContain('LP-99')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).toContain('150% 容量上限')
expect(wrapper.text()).not.toContain('150% 容量上限')
await wrapper.get('button[aria-label="查看装柜说明"]').trigger('mouseenter')
await vi.waitFor(() => expect(document.body.textContent).toContain('150% 容量上限'))
})
it('shows the full inbound composition for an assigned sealed pallet', async () => {
@@ -189,6 +192,11 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('整组加入当前柜次 · CLG-9007199254740999')
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.get('table[aria-label="同柜组成员"]').text()).toContain('ORD-1:IN-201、IN-203')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
@@ -222,9 +230,14 @@ describe('SmartLoadingWorkbenchView', () => {
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('本次计划装柜箱数')
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
const count = dialog.getComponent({ name: 'ElInputNumber' })
expect(count.props('max')).toBe(12)
count.vm.$emit('update:modelValue', 5)
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 5, expectedVersion: planResponse.version })
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -245,6 +258,10 @@ describe('SmartLoadingWorkbenchView', () => {
await flushPromises()
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
await flushPromises()
@@ -1,5 +1,7 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -38,6 +40,12 @@ const noDraft = ref(false)
const editingQuantityId = ref('')
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignmentTitle = computed(() => {
const candidate = selectedCandidate.value
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim()
return code ? `${action} · ${code}` : action
})
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
@@ -307,7 +315,7 @@ async function remove(source: object) {
const message = row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
await ElMessageBox.confirm(message, containerDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', container.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
@@ -386,7 +394,7 @@ onMounted(load)
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案 <InlineHelp title="装柜说明"><p>智能建议必须经人工核对。</p><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></InlineHelp></h2></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
@@ -396,7 +404,7 @@ onMounted(load)
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #toolbar-start><div><h2>候选货物</h2></div></template>
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
@@ -408,7 +416,35 @@ onMounted(load)
</template>
</template>
</template>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<template v-else>
<tr><th>货物摘要</th><td colspan="3"><LineClampText :content="selectedCandidate.goodsSummary || '货物摘要暂缺'" /></td></tr>
<tr><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td><td colspan="2"><el-form-item label="本次计划装柜箱数" label-position="left" label-width="140px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
</template>
</tbody></table>
</el-form>
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
</template>
</div>
</AppDialog>
</section>
</AdminPage>
</template>
@@ -444,8 +480,8 @@ onMounted(load)
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.assignment-table { table-layout: fixed; }
.assignment-table th { width: 90px; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: var(--admin-font-caption); }