From 945a3d3230893a56ca4fee50818076af59b8c8e7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 24 Sep 2026 16:29:31 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=80=80=E7=A8=8E?= =?UTF-8?q?=E6=8A=A5=E5=85=B3=E5=9B=9E=E6=89=A7=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/platformContainerCustoms.test.ts | 8 +++--- .../api/platformContainerCustoms.ts | 7 ++++-- .../components/ContainerCustomsPanel.test.ts | 25 ++++++++++--------- .../components/ContainerCustomsPanel.vue | 23 ++++++----------- 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 199831eb..2cfc01a1 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -4,7 +4,7 @@ import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, dow const customs = { aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n, - planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, + planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12, inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }], documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }], startedAt: null, completedAt: null, @@ -39,14 +39,14 @@ describe('platform container customs API', () => { expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) - it('uploads one completion file with final-confirmation query parameters', async () => { + it('uploads one receipt file without completing the container', async () => { const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' } const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document }) const completion = new File(['done'], 'done.pdf') - await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true) + await uploadContainerCustomsCompletion('2092438843630391407', completion, 0) expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload') expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion) - expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } }) + expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0 }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } }) }) it('keeps source and completion downloads isolated on the long document id', async () => { diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index ccfaa157..e1d7bad2 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -31,6 +31,8 @@ export interface ContainerCustomsDocument { inboundCodes: string[] frozen: boolean completion: ContainerCustomsFile | null + receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null + receiptRejectReason?: string | null } export interface ContainerCustomsView { @@ -45,6 +47,7 @@ export interface ContainerCustomsView { version: number taxRefundCount: number coveredTaxRefundCount: number + actualBoxCount: number inbounds: ContainerCustomsInbound[] documents: ContainerCustomsDocument[] startedAt: string | null @@ -105,13 +108,13 @@ export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVe return action(loadingPlanId, 'complete-all-buy', expectedVersion) } -export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) { +export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number) { const body = new FormData() body.append('file', fileValue) const response = await http.post, ApiEnvelope>( `${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`, body, - { params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } }, + { params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } }, ) const result = unwrapApiResponse(response) return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 2e63b4d2..ef943649 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -28,7 +28,7 @@ import ContainerCustomsPanel from './ContainerCustomsPanel.vue' function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView { return { aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403', - planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, + planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12, inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }], documents: [{ documentId: '2092438843630391407', tenantId: '2092438843630391406', @@ -68,18 +68,19 @@ describe('ContainerCustomsPanel', () => { it('keeps READY_TO_START read-only while exposing source file name and download', async () => { const wrapper = await render('READY_TO_START') expect(wrapper.text()).toContain('开始报关') + expect(wrapper.text()).toContain('实际箱数 12') expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料') - expect(wrapper.text()).toContain('报关完成文件') + expect(wrapper.text()).toContain('报关行回执') expect(wrapper.text()).not.toContain('客户报关资料') expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true) - expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件') - expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件') + expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') + expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') expect(buttonTexts(wrapper)).not.toContain('提交') }) it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => { - api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, inbounds: [], documents: [] }) + api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, actualBoxCount: 0, inbounds: [], documents: [] }) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, @@ -106,19 +107,19 @@ describe('ContainerCustomsPanel', () => { it('allows completion upload and overwrite only while DECLARING with manage permission', async () => { let wrapper = await render('DECLARING', true, false) - expect(wrapper.text()).toContain('上传报关完成文件') + expect(wrapper.text()).toContain('上传报关行回执') expect(wrapper.text()).toContain('提交') wrapper.unmount() wrapper = await render('DECLARING', true, true) - expect(wrapper.text()).toContain('覆盖报关完成文件') + expect(wrapper.text()).toContain('覆盖报关行回执') expect(wrapper.text()).toContain('报关完成文件.pdf') wrapper.unmount() wrapper = await render('DECLARING', false, true) expect(wrapper.text()).toContain('只读') - expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件') - expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件') + expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') + expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') expect(buttonTexts(wrapper)).not.toContain('提交') }) @@ -127,8 +128,8 @@ describe('ContainerCustomsPanel', () => { expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('报关完成文件.pdf') expect(wrapper.text()).toContain('报关已完成,待其他离仓条件') - expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件') - expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件') + expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') + expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') expect(buttonTexts(wrapper)).not.toContain('提交') }) @@ -139,7 +140,7 @@ describe('ContainerCustomsPanel', () => { expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407') expect(api.downloadCompletion).not.toHaveBeenCalled() - await wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click') + await wrapper.get('[aria-label="下载报关行回执 报关完成文件.pdf"]').trigger('click') await flushPromises() expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407') expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 312ceb4b..6002af16 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -38,7 +38,6 @@ const completionFiles = reactive>({}) const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0) -const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0) const statusText: Record = { WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', } @@ -76,19 +75,13 @@ async function uploadCompletion(document: ContainerCustomsDocument) { if (!props.canManage || customs.value?.status !== 'DECLARING') return const selected = completionFiles[document.documentId] if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return } - const finalConfirmation = !document.completion && missingCompletionCount.value === 1 - if (finalConfirmation) { - try { - await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认上传并完成' }) - } catch { return } - } actionLoading.value = true; actionError.value = null try { - await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation) + await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0) completionFiles[document.documentId] = null await load() emit('refreshed') - ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传') + ElMessage.success('报关行回执已上传,等待客户确认') } catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') } finally { actionLoading.value = false } } @@ -168,19 +161,19 @@ onMounted(load) From 62c9eb58be2e491302b74ff6529b4db037dd29bc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 22:45:41 +0800 Subject: [PATCH 13/20] feat(admin): improve sailing cutoff and weekly generation --- .../pages/SailingCalendarView.vue | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 6faccef2..b01f1c90 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -51,6 +51,9 @@ const volumeDetail = ref(null) const adviceOpen = ref(false) const adviceLoading = ref(false) const bookingAdvice = ref(null) +const cutoffMode = ref<'rule' | 'custom'>('rule') +const cutoffDays = ref(3) +const cutoffLocalTime = ref('18:00:00') const form = reactive(blankForm()) const managementOpen = ref(false) const managementTab = ref<'channels' | 'templates'>('channels') @@ -68,9 +71,14 @@ const templateGenerateLoading = ref(false) const templatePreview = ref>([]) const editingTemplateId = ref('') const templateForm = reactive({ templateName: '', channelId: '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '' }) +const generationFrom = ref(formatDate(today)) +const generationTo = ref(addDays(formatDate(today), 56)) function formatMonth(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` } function formatDate(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}` } +function addDays(value: string, days: number) { const date = new Date(`${value}T00:00:00`); date.setDate(date.getDate() + days); return formatDate(date) } +function resetGenerationRange(from = formatDate(today)) { generationFrom.value = from || formatDate(today); generationTo.value = addDays(generationFrom.value, 56) } +function setGenerationWeeks(weeks: number) { generationTo.value = addDays(generationFrom.value || formatDate(today), weeks * 7) } function parseMonth(value: string) { const [year, monthNumber] = value.split('-').map(Number); return new Date(year, monthNumber - 1, 1) } function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function decimal(value: number) { return `${Number(value || 0).toFixed(2)} m³` } @@ -83,10 +91,15 @@ function blankForm(): SailingSaveRequest { } function cutoffFor(etdAt: string) { const value = new Date(etdAt) - value.setDate(value.getDate() - 3) - value.setHours(18, 0, 0, 0) - return `${formatDate(value)}T18:00:00` + value.setDate(value.getDate() - cutoffDays.value) + const [hours, minutes, seconds] = cutoffLocalTime.value.split(':').map(Number) + value.setHours(hours || 0, minutes || 0, seconds || 0, 0) + return `${formatDate(value)}T${cutoffLocalTime.value}` } +function syncCutoffFromRule() { if (cutoffMode.value === 'rule' && form.etdAt) form.cutoffAt = cutoffFor(form.etdAt) } +function onCutoffModeChanged() { syncCutoffFromRule() } +function onCutoffRuleChanged() { syncCutoffFromRule() } +function onCustomCutoffChanged() { cutoffMode.value = 'custom' } const filters = computed(() => data.value?.filterOptions || { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] }) const recordsByDate = computed(() => { @@ -214,8 +227,10 @@ async function saveChannel() { } function openTemplateCreate() { editingTemplateId.value = '' - Object.assign(templateForm, { templateName: '', channelId: channels.value.find((item) => item.status === 'ACTIVE')?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + const activeChannel = channels.value.find((item) => item.status === 'ACTIVE') + Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) templatePreview.value = [] + resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } function openTemplateEdit(row: unknown) { @@ -223,6 +238,7 @@ function openTemplateEdit(row: unknown) { editingTemplateId.value = templateRow.id Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version }) templatePreview.value = [] + resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } @@ -240,11 +256,12 @@ async function saveTemplate() { await openManagement('templates') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '周模板保存失败').message) } finally { templateSaving.value = false } } -function generationRange() { const from = templateForm.effectiveFrom; const to = templateForm.effectiveTo || `${month.value}-28`; return { from, to } } +function generationRange() { return { from: generationFrom.value, to: generationTo.value } } async function previewTemplate(row?: unknown) { const templateRow = row as SailingTemplateResource | undefined if (templateRow) openTemplateEdit(templateRow) if (!validateTemplate()) return + if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } const templateId = templateRow?.id || templates.value.find((item) => item.channelId === templateForm.channelId && item.warehouseId === templateForm.warehouseId && item.version === templateForm.expectedVersion)?.id if (!templateId) { ElMessage.info('请先保存周模板,再进行生成预览'); return } templatePreviewLoading.value = true @@ -252,12 +269,12 @@ async function previewTemplate(row?: unknown) { } async function generateTemplate(row: unknown) { const templateRow = row as SailingTemplateResource - try { await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成选定有效期内的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, { from: templateRow.effectiveFrom, to: templateRow.effectiveTo || `${month.value}-28` }); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false } + try { openTemplateEdit(templateRow); if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, generationRange()); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false } } function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' } -function openCreate() { dialogMode.value = 'create'; Object.assign(form, blankForm()); dialogOpen.value = true } -function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } -function onEtdChanged() { if (dialogMode.value === 'create') form.cutoffAt = cutoffFor(form.etdAt) } +function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true } +function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } return true } async function save() { if (!validateForm()) return @@ -285,7 +302,11 @@ async function copySelected() { const record = selectedSailing.value if (!record || !canEdit.value) return const etd = new Date(record.etdAt); etd.setDate(etd.getDate() + 7) - try { await copySailing(record.id, { etdAt: `${formatDate(etd)}T${record.etdAt.slice(11, 19)}`, cutoffAt: cutoffFor(`${formatDate(etd)}T${record.etdAt.slice(11, 19)}`) }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } + const etdAt = `${formatDate(etd)}T${record.etdAt.slice(11, 19)}` + const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime() + const copiedCutoff = new Date(new Date(etdAt).getTime() - offsetMs) + const cutoffAt = `${formatDate(copiedCutoff)}T${String(copiedCutoff.getHours()).padStart(2, '0')}:${String(copiedCutoff.getMinutes()).padStart(2, '0')}:${String(copiedCutoff.getSeconds()).padStart(2, '0')}` + try { await copySailing(record.id, { etdAt, cutoffAt }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } } async function openVolumeDetail() { if (!selectedSailing.value) return @@ -321,7 +342,8 @@ onMounted(() => void load()) 重置 渠道与周模板 - 新增船期 + 按周批量生成 + 新增单个船次
{{ card.label }}{{ card.value }}
@@ -335,17 +357,17 @@ onMounted(() => void load()) - + 按开船前 N 天自定义时间
提前 2 天提前 3 天提前 4 天
建议截止:{{ dateTime(form.cutoffAt) }}(按开船本地日期倒推自然日)
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}

生成预览({{ templatePreview.length }} 条)

+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

From 5a90503542b5852d5e78fa02aa9646195d5d2583 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 22:50:58 +0800 Subject: [PATCH 14/20] fix(admin): start weekly generation from today --- src/modules/sailing-calendar/pages/SailingCalendarView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index b01f1c90..aee5ddeb 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -77,7 +77,7 @@ const generationTo = ref(addDays(formatDate(today), 56)) function formatMonth(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` } function formatDate(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}` } function addDays(value: string, days: number) { const date = new Date(`${value}T00:00:00`); date.setDate(date.getDate() + days); return formatDate(date) } -function resetGenerationRange(from = formatDate(today)) { generationFrom.value = from || formatDate(today); generationTo.value = addDays(generationFrom.value, 56) } +function resetGenerationRange(from = formatDate(today)) { const todayKey = formatDate(today); generationFrom.value = from && from > todayKey ? from : todayKey; generationTo.value = addDays(generationFrom.value, 56) } function setGenerationWeeks(weeks: number) { generationTo.value = addDays(generationFrom.value || formatDate(today), weeks * 7) } function parseMonth(value: string) { const [year, monthNumber] = value.split('-').map(Number); return new Date(year, monthNumber - 1, 1) } function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } From 3cf676cbbdc06ba80efbaeb2e355e6e1473e57d5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 22:58:22 +0800 Subject: [PATCH 15/20] fix(admin): clarify weekly sailing defaults --- src/modules/sailing-calendar/pages/SailingCalendarView.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index aee5ddeb..0adb2226 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -242,7 +242,7 @@ function openTemplateEdit(row: unknown) { templateDialogOpen.value = true } function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } -function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、船名、起运港、目的港、有效期和截止天数'); return false } return true } +function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、默认船名、起运港、目的港、有效期和截止天数'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } async function saveTemplate() { if (!validateTemplate()) return templateSaving.value = true @@ -275,7 +275,7 @@ function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.ma function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true } function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } -function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } return true } +function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { if (!validateForm()) return saving.value = true @@ -362,7 +362,7 @@ onMounted(() => void load())
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

From 5cc1512a2808b1df5a36ff1a23925cbe476d2d5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 23:41:28 +0800 Subject: [PATCH 16/20] fix(admin): advance sailing copy selection --- .../sailing-calendar/pages/SailingCalendarView.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 0adb2226..6233aea3 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -306,7 +306,16 @@ async function copySelected() { const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime() const copiedCutoff = new Date(new Date(etdAt).getTime() - offsetMs) const cutoffAt = `${formatDate(copiedCutoff)}T${String(copiedCutoff.getHours()).padStart(2, '0')}:${String(copiedCutoff.getMinutes()).padStart(2, '0')}:${String(copiedCutoff.getSeconds()).padStart(2, '0')}` - try { await copySailing(record.id, { etdAt, cutoffAt }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } + try { + const result = await copySailing(record.id, { etdAt, cutoffAt }) + const copied = result.sailing + const targetMonth = copied.etdAt.slice(0, 7) + month.value = targetMonth + selectedDate.value = copied.etdAt.slice(0, 10) + selectedSailing.value = copied + if (targetMonth === data.value?.month) await load() + ElMessage.success(`已定位到下一班:${copied.etdAt.slice(0, 16).replace('T', ' ')}`) + } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } } async function openVolumeDetail() { if (!selectedSailing.value) return From ffe91a1936f3d6416bd7dcdd8ab07a6793b38613 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 09:49:53 +0800 Subject: [PATCH 17/20] feat(admin): show customs receipt history --- .../container-loading/api/platformContainerCustoms.ts | 10 +++++++++- .../components/ContainerCustomsPanel.vue | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index 4f5a374b..e1a839d5 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -12,6 +12,13 @@ export interface ContainerCustomsFile { uploadedAt: string } +export interface ContainerCustomsReceiptVersion { + version: number + file: ContainerCustomsFile + operatorName?: string | null + createdAt: string +} + export interface ContainerCustomsInbound { inboundId: string orderId: string @@ -34,6 +41,7 @@ export interface ContainerCustomsDocument { receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null receiptRejectReason?: string | null finalDocument: ContainerCustomsFile | null + receiptHistory?: ContainerCustomsReceiptVersion[] } export interface ContainerCustomsView { @@ -111,7 +119,7 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView { ...value, aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version), inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })), - documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument) })), + documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument), receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })) })), } } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 5de21e11..a12d80aa 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -58,6 +58,9 @@ const statusText: Record = { function documentCustomers(document: ContainerCustomsDocument) { return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—' } +function receiptHistoryLabel(document: ContainerCustomsDocument) { + return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—' +} function selectReceipt(documentId: string, upload: UploadFile) { if (!props.canManage || customs.value?.status !== 'DECLARING') return receiptFiles[documentId] = upload.raw || null @@ -269,7 +272,7 @@ onMounted(load)
{{ document.source.fileName }}下载
{{ document.receipt.fileName }}下载
未上传
{{ document.finalDocument.fileName }}下载
未上传 - {{ document.inboundCodes.join('、') || '—' }}{{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }}退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }}{{ documentCustomers(document) }}{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} + {{ document.inboundCodes.join('、') || '—' }}{{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }}退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }}回执历史:{{ receiptHistoryLabel(document) }}{{ documentCustomers(document) }}{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} From fad49a07a8cc39ff1759c0f159f5533feb5f9738 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 12:30:23 +0800 Subject: [PATCH 18/20] fix(admin): preserve sailing metadata and generation choices --- .../api/platformSailingCalendar.ts | 5 ++ .../pages/SailingCalendarView.vue | 47 +++++++++++++------ src/modules/sailing-calendar/types.ts | 5 ++ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts index 5b92c1f2..c120a55f 100644 --- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts +++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts @@ -16,6 +16,11 @@ function entry(raw: SailingCalendarEntry): SailingCalendarEntry { warehouseId: String(raw.warehouseId ?? ''), channelCode: String(raw.channelCode ?? ''), channelName: String(raw.channelName ?? raw.channelCode ?? ''), + templateId: raw.templateId == null ? null : String(raw.templateId), + channelId: raw.channelId == null ? null : String(raw.channelId), + etaAt: raw.etaAt ?? null, + sourceType: raw.sourceType ?? null, + plannedCapacityCbm: raw.plannedCapacityCbm == null ? null : number(raw.plannedCapacityCbm), volumeSummary: summary(raw.volumeSummary), version: number(raw.version), } diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 6233aea3..38ab71a3 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -62,6 +62,7 @@ const managementError = ref(null) const channels = ref([]) const channelDialogOpen = ref(false) const channelSaving = ref(false) +const editingChannelId = ref('') const channelForm = reactive({ channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY', mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '' }) const templates = ref([]) const templateDialogOpen = ref(false) @@ -169,6 +170,7 @@ const summaryCards = computed(() => { }) const canCreate = computed(() => access.can('platform:firstleg:sailing-calendar:create')) const canEdit = computed(() => access.can('platform:firstleg:sailing-calendar:edit')) +const canCopy = computed(() => access.can('platform:firstleg:sailing-calendar:create')) const canManageTemplates = computed(() => access.can('platform:firstleg:sailing-template:manage')) async function load() { @@ -214,16 +216,19 @@ async function toggleChannel(row: unknown) { ElMessage.success(channelRow.status === 'ACTIVE' ? '渠道已启用' : '渠道已停用') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道状态更新失败').message) } } -function openChannelCreate() { Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined }); channelDialogOpen.value = true } +function openChannelCreate() { editingChannelId.value = ''; Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined, status: undefined }); channelDialogOpen.value = true } +function openChannelEdit(row: unknown) { const channelRow = row as SailingChannelResource; editingChannelId.value = channelRow.id; Object.assign(channelForm, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode || '', defaultDeparturePortCode: channelRow.defaultDeparturePortCode || '', defaultDestinationPortCode: channelRow.defaultDestinationPortCode || '', defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark || '', expectedVersion: channelRow.version, status: channelRow.status }); channelDialogOpen.value = true } async function saveChannel() { if (!channelForm.channelCode.trim() || !channelForm.channelName.trim() || channelForm.defaultCutoffDays < 0) { ElMessage.error('请填写渠道编码、名称和有效的提前截止天数'); return } channelSaving.value = true try { - const response = await createSailingChannel(channelForm) - channels.value = [...channels.value, response] + const response = editingChannelId.value ? await updateSailingChannel(editingChannelId.value, channelForm) : await createSailingChannel(channelForm) + if (editingChannelId.value) channels.value = channels.value.map((item) => item.id === response.id ? response : item) + else channels.value = [...channels.value, response] channelDialogOpen.value = false - ElMessage.success('渠道已创建') - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道创建失败').message) } finally { channelSaving.value = false } + ElMessage.success(editingChannelId.value ? '渠道已更新' : '渠道已创建') + editingChannelId.value = '' + } catch (cause) { ElMessage.error(getApiErrorDetails(cause, editingChannelId.value ? '渠道更新失败' : '渠道创建失败').message) } finally { channelSaving.value = false } } function openTemplateCreate() { editingTemplateId.value = '' @@ -233,12 +238,12 @@ function openTemplateCreate() { resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function openTemplateEdit(row: unknown) { +function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) { const templateRow = row as SailingTemplateResource editingTemplateId.value = templateRow.id Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version }) templatePreview.value = [] - resetGenerationRange(templateForm.effectiveFrom) + if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } @@ -269,11 +274,25 @@ async function previewTemplate(row?: unknown) { } async function generateTemplate(row: unknown) { const templateRow = row as SailingTemplateResource - try { openTemplateEdit(templateRow); if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, generationRange()); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false } + openTemplateEdit(templateRow) +} +async function confirmTemplateGeneration() { + const templateId = editingTemplateId.value + if (!templateId) { ElMessage.info('请先保存周模板,再生成船期'); return } + if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } + await ElMessageBox.confirm(`确认按当前模板生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }) + templateGenerateLoading.value = true + try { + const result = await generateSailingTemplate(templateId, generationRange()) + ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`) + templateDialogOpen.value = false + await load() + await openManagement('templates') + } finally { templateGenerateLoading.value = false } } function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' } function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true } -function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { @@ -300,7 +319,7 @@ async function cancelSelected() { } async function copySelected() { const record = selectedSailing.value - if (!record || !canEdit.value) return + if (!record || !canCopy.value) return const etd = new Date(record.etdAt); etd.setDate(etd.getDate() + 7) const etdAt = `${formatDate(etd)}T${record.etdAt.slice(11, 19)}` const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime() @@ -363,15 +382,15 @@ onMounted(() => void load())
{{ day }}
- + 按开船前 N 天自定义时间
提前 2 天提前 3 天提前 4 天
建议截止:{{ dateTime(form.cutoffAt) }}(按开船本地日期倒推自然日)
-
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- - 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
+ + 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts index a47a1fda..0e582108 100644 --- a/src/modules/sailing-calendar/types.ts +++ b/src/modules/sailing-calendar/types.ts @@ -36,6 +36,11 @@ export interface SailingCalendarEntry { status: SailingStatus volumeSummary: SailingVolumeSummary version: number + templateId?: string | null + channelId?: string | null + etaAt?: string | null + sourceType?: string | null + plannedCapacityCbm?: number | null remark?: string | null } From 730defabca6bc6750f478829c8894d1865d6a7d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 13:21:59 +0800 Subject: [PATCH 19/20] feat(customs): show broker submission state in admin --- .../api/platformContainerCustoms.test.ts | 12 ++++- .../api/platformContainerCustoms.ts | 50 ++++++++++++++++++- .../components/ContainerCustomsPanel.test.ts | 26 +++++++++- .../components/ContainerCustomsPanel.vue | 36 ++++++++++--- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 7e1a5eb7..3aae0473 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' +import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' const customs = { aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n, planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12, inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }], - documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null }], + documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null, confirmationHistory: [] }], + brokerSubmission: null, startedAt: null, completedAt: null, } @@ -39,6 +40,13 @@ describe('platform container customs API', () => { expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) + it('sends the frozen source documents to the broker through the first-send action', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs }) + await sendContainerCustomsToBroker('2092438843630391402', 3) + expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/send-to-broker', { expectedVersion: 3, confirm: true, remark: '已将冻结退税报关资料首次发送给报关行' }]) + expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) + }) + it('uploads one receipt file without completing the container', async () => { const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' } const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document }) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index e1a839d5..a8bd6067 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -19,6 +19,29 @@ export interface ContainerCustomsReceiptVersion { createdAt: string } +export type ContainerCustomsConfirmationAction = 'CONFIRM' | 'REJECT' + +export interface ContainerCustomsConfirmationHistory { + id: string + receiptVersion: number + action: ContainerCustomsConfirmationAction + reason: string | null + operatorName: string + createdAt: string +} + +export interface ContainerCustomsBrokerSubmission { + id: string + customsContainerId: string + loadingPlanId: string + batchNo: string + status: 'SENT' + sourceVersion: number + sentByName: string + sentAt: string + version: number +} + export interface ContainerCustomsInbound { inboundId: string orderId: string @@ -42,6 +65,7 @@ export interface ContainerCustomsDocument { receiptRejectReason?: string | null finalDocument: ContainerCustomsFile | null receiptHistory?: ContainerCustomsReceiptVersion[] + confirmationHistory: ContainerCustomsConfirmationHistory[] } export interface ContainerCustomsView { @@ -59,6 +83,7 @@ export interface ContainerCustomsView { actualBoxCount: number inbounds: ContainerCustomsInbound[] documents: ContainerCustomsDocument[] + brokerSubmission: ContainerCustomsBrokerSubmission | null startedAt: string | null completedAt: string | null brokerSubmittedAt?: string | null @@ -119,7 +144,24 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView { ...value, aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version), inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })), - documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument), receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })) })), + brokerSubmission: value.brokerSubmission ? { + ...value.brokerSubmission, + id: String(value.brokerSubmission.id), + customsContainerId: String(value.brokerSubmission.customsContainerId), + loadingPlanId: String(value.brokerSubmission.loadingPlanId), + sourceVersion: Number(value.brokerSubmission.sourceVersion), + version: Number(value.brokerSubmission.version), + } : null, + documents: value.documents.map((item) => ({ + ...item, + documentId: String(item.documentId), + tenantId: String(item.tenantId), + source: file(item.source)!, + receipt: file(item.receipt), + finalDocument: file(item.finalDocument), + receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })), + confirmationHistory: (item.confirmationHistory || []).map((history) => ({ ...history, id: String(history.id), receiptVersion: Number(history.receiptVersion), reason: history.reason ?? null })), + })), } } @@ -174,7 +216,7 @@ export function downloadContainerCustomsFinal(documentId: string) { return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' }) } -async function action(loadingPlanId: string, name: 'start' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) { +async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`, { expectedVersion, confirm: true, remark: remark?.trim() || null }, @@ -187,6 +229,10 @@ export function startContainerCustoms(loadingPlanId: string, expectedVersion: nu return action(loadingPlanId, 'start', expectedVersion) } +export function sendContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) { + return action(loadingPlanId, 'send-to-broker', expectedVersion, '已将冻结退税报关资料首次发送给报关行') +} + export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) { return action(loadingPlanId, 'complete-all-buy', expectedVersion) } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 86fce58b..f5fd91b7 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -8,6 +8,8 @@ const api = vi.hoisted(() => ({ get: vi.fn(), start: vi.fn(), completeAllBuy: vi.fn(), + sendToBroker: vi.fn(), + getDeclarationBatches: vi.fn(), upload: vi.fn(), downloadSource: vi.fn(), downloadReceipt: vi.fn(), @@ -23,6 +25,8 @@ vi.mock('../api/platformContainerCustoms', () => ({ downloadContainerCustomsReceipt: api.downloadReceipt, downloadContainerCustomsFinal: api.downloadFinal, submitContainerCustomsToBroker: vi.fn(), + sendContainerCustomsToBroker: api.sendToBroker, + getDeclarationBatches: api.getDeclarationBatches, })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) @@ -39,7 +43,9 @@ function customs(status: ContainerCustomsStatus, hasCompletion = false): Contain inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null, finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null, + confirmationHistory: hasCompletion ? [{ id: '2092438843630391411', receiptVersion: 1, action: 'CONFIRM', reason: null, operatorName: '客户一', createdAt: '2026-08-29T11:15:00' }] : [], }], + brokerSubmission: hasCompletion ? { id: '2092438843630391412', customsContainerId: '2092438843630391401', loadingPlanId: '2092438843630391402', batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } : null, startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null, completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null, } @@ -62,6 +68,7 @@ function buttonTexts(wrapper: Awaited>) { describe('ContainerCustomsPanel', () => { beforeEach(() => { vi.clearAllMocks() + api.getDeclarationBatches.mockResolvedValue([]) api.downloadSource.mockResolvedValue(new Blob(['source'])) api.downloadReceipt.mockResolvedValue(new Blob(['receipt'])) api.downloadFinal.mockResolvedValue(new Blob(['final'])) @@ -112,14 +119,17 @@ describe('ContainerCustomsPanel', () => { it('allows completion upload and overwrite only while DECLARING with manage permission', async () => { let wrapper = await render('DECLARING', true, false) - expect(wrapper.text()).toContain('上传报关行回执') - expect(wrapper.text()).toContain('提交') + expect(wrapper.text()).toContain('首次发送报关行') + expect(wrapper.text()).toContain('首次发送后上传回执') + expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') wrapper.unmount() wrapper = await render('DECLARING', true, true) + expect(wrapper.text()).toContain('发送报关行执行') expect(wrapper.text()).toContain('覆盖报关行回执') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf') + expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') wrapper.unmount() wrapper = await render('DECLARING', false, true) @@ -129,6 +139,18 @@ describe('ContainerCustomsPanel', () => { expect(buttonTexts(wrapper)).not.toContain('提交') }) + it('loads declaration batches during DECLARING after broker execution was sent', async () => { + const value = { ...customs('DECLARING', true), brokerSubmittedAt: '2026-08-29T11:20:00' } + api.get.mockResolvedValue(value) + const wrapper = mount(ContainerCustomsPanel, { + props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + expect(api.getDeclarationBatches).toHaveBeenCalledWith('2092438843630391402') + wrapper.unmount() + }) + it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => { const wrapper = await render('COMPLETED', true, true) expect(wrapper.text()).toContain('客户退税资料.pdf') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index a12d80aa..d02b4a41 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -19,6 +19,7 @@ import { resolveClearanceResult, submitDeclarationBatch, startContainerCustoms, + sendContainerCustomsToBroker, submitContainerCustomsToBroker, uploadContainerCustomsReceipt, uploadContainerCustomsFinal, @@ -49,7 +50,9 @@ const finalFiles = reactive>({}) const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0) +const brokerSubmission = computed(() => customs.value?.brokerSubmission || null) const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt)) +const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true) const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null) const statusText: Record = { WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', @@ -61,8 +64,16 @@ function documentCustomers(document: ContainerCustomsDocument) { function receiptHistoryLabel(document: ContainerCustomsDocument) { return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—' } +function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) { + return action === 'CONFIRM' ? '确认' : '打回' +} +function formatDateTime(value: string | null | undefined) { + if (!value) return '—' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) +} function selectReceipt(documentId: string, upload: UploadFile) { - if (!props.canManage || customs.value?.status !== 'DECLARING') return + if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return receiptFiles[documentId] = upload.raw || null actionError.value = null } @@ -78,7 +89,7 @@ async function load() { loadError.value = null try { customs.value = await getContainerCustoms(props.loadingPlanId) - if (customs.value?.status === 'COMPLETED') { + if (customs.value?.status === 'COMPLETED' || customs.value?.brokerSubmittedAt) { try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] } } else declarationBatches.value = [] } @@ -147,7 +158,7 @@ async function start() { } async function uploadReceipt(document: ContainerCustomsDocument) { - if (!props.canManage || customs.value?.status !== 'DECLARING' || brokerSubmitted.value) return + if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return const selected = receiptFiles[document.documentId] if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return } actionLoading.value = true; actionError.value = null @@ -203,7 +214,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | } async function submitToBroker() { - if (!customs.value || customs.value.status !== 'DECLARING') return + 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 } actionLoading.value = true; actionError.value = null try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') } @@ -211,6 +222,15 @@ async function submitToBroker() { finally { actionLoading.value = false } } +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 } + actionLoading.value = true; actionError.value = null + try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') } + catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') } + finally { actionLoading.value = false } +} + async function completeAllBuy() { if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) } @@ -249,7 +269,8 @@ onMounted(load) {{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }} 导出买单 XLSX 开始报关 - 发送报关行执行 + 首次发送报关行 + 发送报关行执行 确认报关完成 @@ -260,6 +281,7 @@ onMounted(load) + - - diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts index 0e582108..723249e2 100644 --- a/src/modules/sailing-calendar/types.ts +++ b/src/modules/sailing-calendar/types.ts @@ -25,8 +25,8 @@ export interface SailingCalendarEntry { warehouseName: string channelCode: string channelName: string - vesselName: string - voyageNo: string + vesselName: string | null + voyageNo: string | null departurePortCode: string departurePortName?: string | null destinationPortCode: string @@ -85,8 +85,8 @@ export interface SailingSaveRequest { warehouseName?: string | null channelCode: string channelName: string - vesselName: string - voyageNo: string + vesselName: string | null + voyageNo: string | null departurePortCode: string departurePortName?: string | null destinationPortCode: string diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 09f8a540..a815ebb7 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -249,7 +249,7 @@ describe('UnifiedOrdersView contract', () => { it('counts detail in the three directly visible row actions', () => { expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }") expect(source).toContain('...unifiedOrderSecondaryActions(row).map') - expect(source).toContain(' { 打印箱唛({{ selectedCartonLabelOrders.length }}) 智能装柜 - +