diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index 4afed29c..afa7446e 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -188,7 +188,7 @@ onMounted(load)
-
+
diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 6970882d..a7653528 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -1,10 +1,10 @@ -import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus' +import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' -import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading' +import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading' import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings' import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages' import { listSuppliers } from '@/modules/suppliers' @@ -59,7 +59,7 @@ function accessibleText(wrapper: { element: Element } | undefined) { describe('ContainerDetailView empty return', () => { it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => { vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never) - vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }]) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }]) const wrapper = await mountDetail() const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent expect(value).toBe('2026-10-23(默认推算)') @@ -100,7 +100,8 @@ describe('ContainerDetailView empty return', () => { vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' }) const wrapper = await mountDetail() expect(getDirectDeliveries).not.toHaveBeenCalled() - await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises() + expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false) + await (wrapper.vm as unknown as { openDirect(): Promise }).openDirect(); await flushPromises() 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() @@ -123,7 +124,8 @@ describe('ContainerDetailView empty return', () => { canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm') 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() + expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false) + await (wrapper.vm as unknown as { openDirect(): Promise }).openDirect(); await flushPromises() 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('签收事实只读') @@ -206,17 +208,20 @@ describe('ContainerDetailView empty return', () => { granted.value = true await flushPromises() expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1) - expect(wrapper.text()).toContain('已确认离仓') + expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false) + expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED') expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁') expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓') }) it('reloads authoritative departure state once on each newly opened domestic detail', async () => { const first = await mountDetail('DOMESTIC') - expect(first.text()).toContain('已确认离仓') + expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false) + expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED') first.unmount() const reopened = await mountDetail('DOMESTIC') - expect(reopened.text()).toContain('已确认离仓') + expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false) + expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED') expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2) }) @@ -437,3 +442,54 @@ describe('domestic container loading list and booking link', () => { expect(routerPush).not.toHaveBeenCalled() }) }) + + +describe('destination document automatic upload', () => { + const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null } + beforeEach(() => { + canAccess.mockReset().mockReturnValue(true) + routeState.query = {} + vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never) + vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) + vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never) + vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never) + vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never) + vi.mocked(completeContainerClearance).mockReset() + }) + it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => { + const wrapper = await mountDetail() + await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type) + await flushPromises() + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())! + expect(dialog.props('showConfirm')).toBe(false) + const file = new File(['data'], '资料.pdf', { type: 'application/pdf' }) + const select = dialog.findComponent(ElUpload).props('onChange')! + select({ raw: file } as never, []) + select({ raw: file } as never, []) + await flushPromises() + const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance + expect(upload).toHaveBeenCalledTimes(1) + expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)])) + expect(completeContainerClearance).not.toHaveBeenCalled() + expect(dialog.props('modelValue')).toBe(false) + }) + it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => { + const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance + vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断')) + const wrapper = await mountDetail() + await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type) + await flushPromises() + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())! + const file = new File(['data'], '资料.pdf', { type: 'application/pdf' }) + dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, []) + await flushPromises() + expect(dialog.props('modelValue')).toBe(true) + expect(dialog.text()).toContain('上传失败') + await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click') + await flushPromises() + expect(upload).toHaveBeenCalledTimes(2) + expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0]) + expect(dialog.props('modelValue')).toBe(false) + expect(completeContainerClearance).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 989caa49..a375411f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -19,10 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules' import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists' import { - confirmContainerOutboundDispatch, getContainerOutboundDispatch, type ContainerOutboundDispatchDetail, - type ContainerOutboundGateItem, } from '@/modules/outbound-dispatch' import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue' import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue' @@ -244,7 +242,6 @@ async function saveDirect() { } const canListCustoms = computed(() => access.can('platform:firstleg:customs:list')) const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view')) -const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm')) const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const canViewCurrentLoadingList = computed(() => { @@ -274,6 +271,7 @@ const pickupCodeSaving = ref(false) const clearanceOpen = ref(false) const clearanceDocument = ref(null) const clearanceFile = ref(null) +const clearanceUploadKey = ref(0) const clearanceLoading = ref(false) const clearanceSaving = ref(false) const clearanceError = ref(null) @@ -282,10 +280,8 @@ const exceptionReason = ref('') const exceptionSaving = ref(false) const exceptionError = ref(null) const outboundLoading = ref(false) -const outboundConfirming = ref(false) const outboundDetail = ref(null) const outboundError = ref(null) -const showPassedGates = ref(false) const carrierRefreshKey = ref(0) const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail() const emptyReturnOpen = ref(false) @@ -337,32 +333,6 @@ const emptyReturnWarningText = computed(() => { return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}` }) const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') -type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] } -const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE']) -const outboundGateRows = computed(() => { - const grouped = new Map() - for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) { - for (const item of inbound.gate.items || []) { - if (referenceOnlyGateCodes.has(item.code)) continue - const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name]) - const existing = grouped.get(key) - if (existing) { - if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode) - } else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] }) - } - } - return [...grouped.values()] -}) -const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed)) -const outboundGateSummary = computed(() => ({ - total: outboundGateRows.value.length, - passed: outboundGateRows.value.filter((row) => row.passed).length, - blocked: outboundGateRows.value.filter((row) => !row.passed).length, -})) -const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value - && hasAction('CONFIRM_OUTBOUND') - && outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH' - && outboundGateSummary.value.blocked === 0) const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() => !outboundDetail.value ? 'UNKNOWN' : outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES') @@ -469,7 +439,7 @@ watch( async function loadOutboundDispatch() { if (!containerId.value || !canViewOutboundDispatch.value) return - outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false + outboundLoading.value = true; outboundError.value = null try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) } catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') } finally { outboundLoading.value = false } @@ -488,37 +458,6 @@ async function refreshAfterCustoms() { try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ } } -async function confirmDeparture() { - const aggregate = outboundDetail.value?.aggregate - if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return - outboundConfirming.value = true - try { - await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' }) - } catch { outboundConfirming.value = false; return } - outboundError.value = null - try { - outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, { - expectedVersion: aggregate.version, - remark: '货柜详情确认国内出仓', - }) - carrierRefreshKey.value += 1 - ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新') - try { overview.value = await getContainerOverview(containerId.value) } - catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') } - } catch (cause) { - const details = getApiErrorDetails(cause, '确认国内出仓失败') - if (details.code === 'A0409') await loadOutboundDispatch() - outboundError.value = details - } finally { outboundConfirming.value = false } -} - -function outboundStatusText(status?: string | null) { - return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—' -} -function truckReferenceText(status?: string | null) { - return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[status || ''] || status || '未关联' -} - function openDocument(type: string) { if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return } if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return @@ -526,9 +465,14 @@ function openDocument(type: string) { documentType.value = type as UploadDocumentType documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true } -function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null } +function selectDocumentFile(file: UploadFile) { + if (documentSaving.value) return + documentFile.value = file.raw || null; documentError.value = null + if (documentFile.value) void saveDocument() +} function removeDocumentFile() { documentFile.value = null } async function saveDocument() { + if (documentSaving.value || !canManageDestinationDocuments.value) return if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return } documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null try { @@ -601,14 +545,20 @@ async function saveException() { } async function openClearance() { + clearanceUploadKey.value += 1 clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true try { clearanceDocument.value = await getContainerClearance(containerId.value) } catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details } finally { clearanceLoading.value = false } } -function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null } +function selectClearanceFile(file: UploadFile) { + if (clearanceSaving.value || clearanceLoading.value) return + clearanceFile.value = file.raw || null; clearanceError.value = null + if (clearanceFile.value) void uploadClearance() +} function removeClearanceFile() { clearanceFile.value = null } async function uploadClearance() { + if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null try { @@ -697,7 +647,7 @@ onMounted(async () => {

船名航次信息

船公司
{{ detail.shippingCompanyName || '—' }}
船名 / 航次
{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
预计开船
{{ formatTime(detail.estimatedSailingAt) }}
预计到港
{{ formatTime(detail.estimatedPortArrivalAt) }}
-
+
@@ -710,26 +660,10 @@ onMounted(async () => {

装柜作业与国内出仓

-
-

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}确认离仓
- -
- -
-

提柜、卸柜与还空

提柜安排与执行直送卸货清单确认还柜
+

提柜、卸柜与还空

提柜安排与执行确认还柜
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ expectedArrivalText }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
@@ -753,9 +687,9 @@ onMounted(async () => { -
选择文件
+
选择文件
- + diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index 3cdf483c..7e08f302 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -44,3 +44,5 @@ - 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。 - 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。 + +- 车辆详情与司机详情通过 `ResourcePhotoPanel.vue` 维护常用照片,接口 `api/resourcePhotos.ts`/`fleet-resource-photos-v1.yaml`;车辆沿vehicle:edit、司机沿driver:upload,读取沿各自list。照片版本独立于档案字段版本,替换保留历史且不改变驾驶证审核;排车缺照片时由服务端复制为当趟证据。只在详情加载元数据、点击预览读取图片,不逐行请求。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 23da3024..6292122e 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -1,5 +1,9 @@ # 海外通用资料原型核对矩阵 +## 档案照片复用(2026-10-26) + +依据用户确认优化重复上传:车辆概览和司机详情增加常用照片维护,沿既有详情分区与620px居中预览;元数据按详情加载,原图点击才读。车辆编辑权限与司机上传权限分别控制替换。排车选档案后自动带入并保存当趟副本;沿用Admin详情、提示及表单规范。自动行为验证与类型检查单独报告,1920×1080、100%截图待人工确认。 + ## 散板需求只读展示(2026-10-21) 依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。 @@ -367,3 +371,9 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码 | OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 | 依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。 + +## 装车资料多图(2026-10-26) + +依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。 + +- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。 diff --git a/src/modules/overseas-master-data/api/resourcePhotos.test.ts b/src/modules/overseas-master-data/api/resourcePhotos.test.ts new file mode 100644 index 00000000..f5292f38 --- /dev/null +++ b/src/modules/overseas-master-data/api/resourcePhotos.test.ts @@ -0,0 +1,15 @@ +import { expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto } from './resourcePhotos' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn() } })) +it('preserves long identifiers and versions and submits the correct resource scope', async () => { + const id = '9223372036854775701', fileId = '9223372036854775702', version = '9007199254740993' + const wire = { code: '200', data: { recordVersion: version, photo: { fileId, fileName: 'photo.png', contentType: 'image/png', sizeBytes: '3' } } } + vi.mocked(http.get).mockResolvedValue(wire); vi.mocked(http.post).mockResolvedValue(wire) + expect(await getResourcePhoto('VEHICLE', id)).toMatchObject({ recordVersion: version, photo: { fileId, sizeBytes: 3 } }) + await uploadResourcePhoto('VEHICLE', id, version, new File(['png'], 'photo.png', { type: 'image/png' })) + expect(http.post).toHaveBeenCalledWith(`/platform/v1/last-leg/fleet/resource-photos/VEHICLE/${id}`, expect.any(FormData)) + expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('recordVersion')).toBe(version) + vi.mocked(http.get).mockResolvedValue(new Blob(['{"code":"A0403"}'], { type: 'application/json' })) + await expect(downloadResourcePhoto('VEHICLE', id, fileId)).rejects.toThrow('读取失败') +}) diff --git a/src/modules/overseas-master-data/api/resourcePhotos.ts b/src/modules/overseas-master-data/api/resourcePhotos.ts new file mode 100644 index 00000000..4c887211 --- /dev/null +++ b/src/modules/overseas-master-data/api/resourcePhotos.ts @@ -0,0 +1,22 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type ResourcePhotoKind = 'VEHICLE' | 'DRIVER' +export interface ResourcePhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number } +export interface ResourcePhotoDetail { recordVersion: string; photo: ResourcePhoto | null } +const path = (kind: ResourcePhotoKind, id: string) => `/platform/v1/last-leg/fleet/resource-photos/${kind}/${encodeURIComponent(id)}` +type Wire = { recordVersion: string | number; photo: (Omit & { sizeBytes: number | string }) | null } +function normalize(value: Wire): ResourcePhotoDetail { + return { recordVersion: String(value.recordVersion), photo: value.photo ? { ...value.photo, sizeBytes: Number(value.photo.sizeBytes) } : null } +} +export async function getResourcePhoto(kind: ResourcePhotoKind, id: string) { + return normalize(unwrapApiResponse(await http.get, ApiEnvelope>(path(kind, id)))) +} +export async function uploadResourcePhoto(kind: ResourcePhotoKind, id: string, version: string, file: File) { + const body = new FormData(); body.append('recordVersion', version); body.append('file', file) + return normalize(unwrapApiResponse(await http.post, ApiEnvelope>(path(kind, id), body))) +} +export async function downloadResourcePhoto(kind: ResourcePhotoKind, id: string, fileId: string) { + const blob = await http.get(`${path(kind, id)}/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' }) + if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size || blob.size > 10 * 1024 * 1024) throw new Error('档案照片读取失败,请重新核对') + return blob +} diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts index be6008fd..383187b9 100644 --- a/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts @@ -24,7 +24,7 @@ describe('OutsourcedBusiness presentation boundaries', () => { const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } }) expect(wrapper.findComponent(Dialog).props('width')).toBe('760px') expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false) - expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证']) + expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字']) expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传') }) @@ -37,12 +37,15 @@ describe('OutsourcedBusiness presentation boundaries', () => { expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled() }) - it('keeps an unknown operations request until its owner explicitly handles closing', async () => { + it('unlocks operations closing when the request ends and still respects the owner guard', async () => { const done = vi.fn(), guard = vi.fn() - const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } }) + const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } }) const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void close(done); expect(done).not.toHaveBeenCalled() + await wrapper.setProps({ busy: false }); close(done) + expect(done).toHaveBeenCalledTimes(1) await wrapper.setProps({ beforeClose: guard }); close(done) - expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled() + expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1) + wrapper.unmount() }) }) diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue index 408dc149..00d66064 100644 --- a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue @@ -19,7 +19,7 @@ function close(done: () => void) { diff --git a/src/modules/overseas-master-data/components/OwnedBusinessLayouts.test.ts b/src/modules/overseas-master-data/components/OwnedBusinessLayouts.test.ts index 53e7cb2d..6483611d 100644 --- a/src/modules/overseas-master-data/components/OwnedBusinessLayouts.test.ts +++ b/src/modules/overseas-master-data/components/OwnedBusinessLayouts.test.ts @@ -24,7 +24,7 @@ describe('OwnedBusiness presentation boundaries', () => { const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } }) expect(wrapper.findComponent(Dialog).props('width')).toBe('760px') expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false) - expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证', '起始油耗表盘', '结束油耗表盘']) + expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字', '起始油耗表盘', '结束油耗表盘']) expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传') }) @@ -37,12 +37,15 @@ describe('OwnedBusiness presentation boundaries', () => { expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled() }) - it('keeps an unknown operations request until its owner explicitly handles closing', async () => { + it('unlocks operations closing when the request ends and still respects the owner guard', async () => { const done = vi.fn(), guard = vi.fn() - const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } }) + const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } }) const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void close(done); expect(done).not.toHaveBeenCalled() + await wrapper.setProps({ busy: false }); close(done) + expect(done).toHaveBeenCalledTimes(1) await wrapper.setProps({ beforeClose: guard }); close(done) - expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled() + expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1) + wrapper.unmount() }) }) diff --git a/src/modules/overseas-master-data/components/OwnedBusinessPhotosDialog.vue b/src/modules/overseas-master-data/components/OwnedBusinessPhotosDialog.vue index 309b9f4c..4eec9108 100644 --- a/src/modules/overseas-master-data/components/OwnedBusinessPhotosDialog.vue +++ b/src/modules/overseas-master-data/components/OwnedBusinessPhotosDialog.vue @@ -10,13 +10,13 @@ const props = defineProps<{ }>() const open = defineModel({ default: false }) const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' }) -// These are prototype presentation slots; the public file categories are not yet frozen. +// 展示槽位沿用既有引用,标签与排车装车资料一致。 const loadingPhotos = [ - { slot: 'loading-scene', label: '装车现场' }, - { slot: 'loading-list', label: '装车清单' }, - { slot: 'pre-departure-check', label: '出车前检查' }, - { slot: 'driver-license', label: '驾驶证附件' }, - { slot: 'delivery-receipt', label: '签收凭证' }, + { slot: 'loading-scene', label: '司机驾驶证' }, + { slot: 'loading-list', label: '车牌照片' }, + { slot: 'pre-departure-check', label: '装车完成' }, + { slot: 'driver-license', label: '封车照片' }, + { slot: 'delivery-receipt', label: 'BOL司机签字' }, ] const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }] function close(done: () => void) { @@ -28,7 +28,7 @@ function close(done: () => void) { diff --git a/src/modules/overseas-master-data/components/ResourcePhotoPanel.test.ts b/src/modules/overseas-master-data/components/ResourcePhotoPanel.test.ts new file mode 100644 index 00000000..d340c3b8 --- /dev/null +++ b/src/modules/overseas-master-data/components/ResourcePhotoPanel.test.ts @@ -0,0 +1,54 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import ResourcePhotoPanel from './ResourcePhotoPanel.vue' +import * as api from '../api/resourcePhotos' +const security = vi.hoisted(() => ({ writable: true })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => security.writable }) })) +vi.mock('../api/resourcePhotos', () => ({ getResourcePhoto: vi.fn(), uploadResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() })) +const photo = { fileId: '81', fileName: 'driver.png', contentType: 'image/png', sizeBytes: 3 } +const options = { props: { kind: 'DRIVER' as const, resourceId: '7' }, global: { directives: { loading: () => undefined }, stubs: { + ElButton: { props: ['disabled'], template: '' }, + AppDialog: { props: ['modelValue'], template: '
' }, +} } } +beforeEach(() => { + vi.resetAllMocks(); security.writable = true + vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo }) + URL.createObjectURL = vi.fn().mockReturnValue('blob:photo'); URL.revokeObjectURL = vi.fn() +}) +it('keeps reading independent from upload permission and downloads only on preview', async () => { + security.writable = false + vi.mocked(api.downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' })) + const wrapper = mount(ResourcePhotoPanel, options); await flushPromises() + expect(wrapper.text()).not.toContain('上传 / 替换') + expect(api.downloadResourcePhoto).not.toHaveBeenCalled() + await wrapper.get('button').trigger('click'); await flushPromises() + expect(api.downloadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '81') + wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo') +}) +it('submits the independent photo version and releases busy state after failure', async () => { + vi.mocked(api.uploadResourcePhoto).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409')) + .mockResolvedValueOnce({ recordVersion: '3', photo }) + const wrapper = mount(ResourcePhotoPanel, options); await flushPromises() + const input = wrapper.get('input[type="file"]'), file = new File(['png'], 'driver.png', { type: 'image/png' }) + Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }) + await input.trigger('change'); await flushPromises() + expect(api.uploadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '2', file) + expect(wrapper.text()).toContain('版本冲突') + await input.trigger('change'); await flushPromises() + expect(api.uploadResourcePhoto).toHaveBeenCalledTimes(2) + wrapper.unmount() +}) +it('does not apply an upload result to a different resource', async () => { + let finish!: (value: api.ResourcePhotoDetail) => void + vi.mocked(api.uploadResourcePhoto).mockImplementation(() => new Promise(resolve => { finish = resolve })) + const wrapper = mount(ResourcePhotoPanel, options); await flushPromises() + const input = wrapper.get('input[type="file"]') + Object.defineProperty(input.element, 'files', { value: [new File(['png'], 'driver.png', { type: 'image/png' })] }) + await input.trigger('change') + vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null }) + await wrapper.setProps({ resourceId: '8' }); await flushPromises() + finish({ recordVersion: '3', photo }); await flushPromises() + expect(wrapper.text()).toContain('尚未上传司机照片') + wrapper.unmount() +}) diff --git a/src/modules/overseas-master-data/components/ResourcePhotoPanel.vue b/src/modules/overseas-master-data/components/ResourcePhotoPanel.vue new file mode 100644 index 00000000..7500ac72 --- /dev/null +++ b/src/modules/overseas-master-data/components/ResourcePhotoPanel.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts index 428cec2f..0374686f 100644 --- a/src/modules/overseas-master-data/index.ts +++ b/src/modules/overseas-master-data/index.ts @@ -18,3 +18,6 @@ export { normalizeTripDetail } from './api/trips' export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes' export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups' export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups' + +export { getResourcePhoto, downloadResourcePhoto } from './api/resourcePhotos' +export type { ResourcePhotoKind } from './api/resourcePhotos' diff --git a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts index 24b30b93..b0f6bbee 100644 --- a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts +++ b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts @@ -21,16 +21,16 @@ export interface OutsourcedBusinessPage { records: T[]; page: number; size: n export interface OutsourcedBusinessList extends OutsourcedBusinessPage { summary: OutsourcedBusinessSummary } export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] } export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null } -export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string } +export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string } export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO' export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] } export const outsourcedBusinessStages: Record = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } export const outsourcedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [ - { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' }, - { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' }, - { slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, + { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' }, + { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' }, + { slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' }, ] export function outsourcedBusinessVersion(value: unknown): string { if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取') diff --git a/src/modules/overseas-master-data/ownedBusinessTypes.ts b/src/modules/overseas-master-data/ownedBusinessTypes.ts index 1672689b..ecb4eba2 100644 --- a/src/modules/overseas-master-data/ownedBusinessTypes.ts +++ b/src/modules/overseas-master-data/ownedBusinessTypes.ts @@ -21,16 +21,16 @@ export interface OwnedBusinessPage { records: T[]; page: number; size: number export interface OwnedBusinessList extends OwnedBusinessPage { summary: OwnedBusinessSummary } export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] } export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null } -export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string } +export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string } export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO' export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] } export const ownedBusinessStages: Record = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } export const ownedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [ - { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' }, - { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' }, - { slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' }, + { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' }, + { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' }, + { slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' }, ] export function ownedBusinessVersion(value: unknown): string { if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取') diff --git a/src/modules/overseas-master-data/pages/DriverDetailView.vue b/src/modules/overseas-master-data/pages/DriverDetailView.vue index 2734d40d..b35f910f 100644 --- a/src/modules/overseas-master-data/pages/DriverDetailView.vue +++ b/src/modules/overseas-master-data/pages/DriverDetailView.vue @@ -1,4 +1,5 @@ - + diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index 41f836a0..db0dfbe0 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -58,7 +58,7 @@ ## 验证 -- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。 +- 排车调度/理货审核分区仅显示审核、详情;车辆图片与司机照片优先从所选主档自动带入,保存时复制为本趟附件;已有当趟照片保留,主档缺失或临时外配时通过既有本车附件接口补传。驾驶证沿主档引用且接受PDF/JPG/PNG;司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。 - 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。 - 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。 diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index f2fc6cef..224dc85c 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -317,6 +317,7 @@ | 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 | | 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 | | 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 | +| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 | | 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 | | 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 | | 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 | @@ -448,3 +449,27 @@ | 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 | | 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 | + + +## 灰度界面精简(2026-10-26) + +依据:本轮用户截图 1—7 及后续五项确认;Admin 基于 main `952abd56` 的 `hotfix-gray-bugfix`。已有入库短收改动保留。 + +| 页面 | 操作 | 确认布局与实现 | 验证 | +|---|---|---|---| +| 尾程订单 | 入库计划 | 保留原居中、可全屏弹框;移除顶部说明,托数使用紧凑输入框,说明靠近输入并自然换行;保存生成入库单及预计到仓锁定规则不变 | 模板类型检查通过;1920×1080、100%待人工截图确认 | +| 尾程订单 | 修改节点 | 保留原居中弹框;去掉黄色提示及分区背景,只显示国内报关完成、国外清关完成;读取并原样回传隐藏节点已有值 | 两项编辑、隐藏节点保留、零版本提交回归通过;待人工截图确认 | +| 排车列表 | 调度审核图片 | 保持三类图片横向并排;分区顶部对齐,多图按可用宽度并排;缩略图保持完整比例,长文件名省略且可悬停查看,保留大图预览 | 附件组件19项、审核布局29项通过;待人工截图确认 | +| 清关提柜详情 | DO/目的港清关附件 | 保留上传弹框,选中文件即上传保存;上传中防重复,失败保留文件可重试;目的港清关完成仍是独立业务动作 | 自动上传、防重复、失败重试共4项回归通过;待人工截图确认 | + +续修验证:修正与当前开船阶段、服务端到仓投影和默认白色分区不一致的旧测试,新增未开船订单只读回归;补齐尾程费用表单的可编辑表格标记、统一订单状态按钮的共享高度变量。Admin 7个文件265项定向检查全部通过,Vue类型检查通过。PDA 381项全量测试全部通过(失败重试按现行规则使用新键与当前输入);此前 H5 与 App 资源构建通过。自动检查不代替真实服务、PDA真机或人工视觉验收。 + +## 档案照片自动带入(2026-10-26) + +依据用户确认优化重复上传:维护车辆/司机沿原三列照片布局,缺少本趟照片时展示所选主档照片,并显示保存后用于本趟;允许本趟补传覆盖默认来源。驾驶证允许PDF/JPG/PNG。普通派送及到仓取货均由服务端保存档案照片副本,历史排车保持原图。自动行为测试与类型检查单独报告;布局待人工1920×1080、100%截图确认。 + +## 装车资料多图(2026-10-26) + +依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。 + +- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。 diff --git a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts index 4d5e1d67..c739294c 100644 --- a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts +++ b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts @@ -3,9 +3,10 @@ import type { DispatchStage } from '../dispatchContracts' export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const export type LoadingPhotoSlot = typeof loadingPhotoSlots[number] +export const loadingPhotoLabels: Record = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' } export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null } export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] } -export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string } +export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string } const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents` export async function getLoadingDocuments(no: string) { return unwrapApiResponse(await http.get, ApiEnvelope>(path(no))) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.test.ts b/src/modules/overseas-warehouse/api/inboundPlans.test.ts index 407687ae..aafc6452 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.test.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { getInboundPlan, listInboundPlans } from './inboundPlans' +import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans' -vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('inbound plan overseas API', () => { beforeEach(() => vi.mocked(http.get).mockReset()) + it('confirms the current shortage with customer, version and idempotency key', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } }) + const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' } + await confirmInboundShortage('IP/1', payload, 'request-key') + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } }) + }) it('pages plans with explicit customer scope', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' }) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index e31767d2..aa9ab80b 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -45,6 +45,7 @@ export interface InboundPlanSummaryDto { } export interface InboundPlanDetailDto { + version?: number id: string planNo: string customerId: string @@ -81,6 +82,21 @@ export async function getInboundPlan(planNo: string, customerId: string) { return unwrapApiResponse(await http.get, ApiEnvelope>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } })) } +export interface ConfirmInboundShortagePayload { + customerId: string + version: number + expectedPackageCount: number + receivedPackageCount: number + reason: string +} + +export async function confirmInboundShortage(planNo: string, payload: ConfirmInboundShortagePayload, idempotencyKey: string) { + return unwrapApiResponse(await http.post, ApiEnvelope<{ status: string }>>( + `/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/confirm-shortage`, payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + )) +} + export interface PlatformInventoryMoveRequest { overseasWarehouseId: string sourcePalletCode: string diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts index 2ac0065f..be4e285a 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts @@ -6,13 +6,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' import * as api from '../api/dispatches' import type { DispatchEvidenceFile } from '../dispatchContracts' -import { downloadDriverLicense } from '@/modules/overseas-master-data' -vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn() })) +import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto } from '@/modules/overseas-master-data' +vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() })) vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() })) const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 }) const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '
' } } } } const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(value => value.text() === label)! -it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not count a PDF or failed upload as a %s photo', async kind => { +it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed upload as a %s photo', async kind => { const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises() await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' })) expect(api.uploadDispatchEvidence).not.toHaveBeenCalled() @@ -154,3 +154,36 @@ it('rejects mismatched file identity and a blocked PDF popup without fetching co vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null) const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore() }) + +it('accepts a PDF driver license in the photo card without requiring another image', async () => { + vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302', 'application/pdf')) + const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: [] } }) + await flushPromises(); await selectFile(wrapper, new File(['pdf'], 'license.pdf', { type: 'application/pdf' })) + expect(api.uploadDispatchEvidence).toHaveBeenCalledOnce() + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); wrapper.unmount() +}) +it('previews a master photo without submitting its foreign file ID as dispatch evidence', async () => { + vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '1', photo: metadata('81') }) + vi.mocked(downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' })) + const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } }) + await flushPromises() + expect(wrapper.text()).toContain('已带入档案照片') + expect(api.getDispatchEvidence).not.toHaveBeenCalled() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.emitted('available')?.at(-1)).toEqual([true]) + await wrapper.setProps({ modelValue: ['301'] }); await flushPromises() + expect(wrapper.text()).not.toContain('已带入档案照片') + expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301') + expect(getResourcePhoto).toHaveBeenCalledOnce(); wrapper.unmount() +}) +it('ignores late profile photo responses after changing the selected resource', async () => { + let finish!: (value: Awaited>) => void + vi.mocked(getResourcePhoto).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + .mockResolvedValueOnce({ recordVersion: '0', photo: null }) + const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } }) + await wrapper.setProps({ masterResource: { kind: 'VEHICLE', id: '8' } }); await flushPromises() + finish({ recordVersion: '1', photo: metadata('81') }); await flushPromises() + expect(downloadResourcePhoto).not.toHaveBeenCalled() + expect(wrapper.text()).not.toContain('已带入档案照片') + expect(wrapper.emitted('available')?.at(-1)).toEqual([false]); wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue index fa15063e..d1a958aa 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue @@ -9,12 +9,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches' -import { downloadDriverLicense } from '@/modules/overseas-master-data' +import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } from '@/modules/overseas-master-data' import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts' -const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterLicense?: { driverId: string; fileId: string } | null }>() +const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>() const ids = defineModel({ required: true }) -const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])]) -const emit = defineEmits<{ busy: [value: boolean] }>() +const profilePhotoId = ref(null) +const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])]) +const emit = defineEmits<{ busy: [value: boolean]; available: [value: boolean] }>() const metadata = ref>({}), error = ref(null), uploading = ref(false), input = ref(null) const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false) const thumbnails = ref>({}) @@ -23,8 +24,8 @@ const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('') const previewBusy = computed(() => previewLoading.value || imageDecoding.value) const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照')) const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set() -const uploadTypes = computed(() => (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind)) ? types.slice(1) : types) -const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG') +const uploadTypes = computed(() => (props.kind !== 'DRIVER' && (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind))) ? types.slice(1) : types) +const uploadFormats = computed(() => uploadTypes.value.includes('application/pdf') ? 'PDF、JPG、PNG' : 'JPG、PNG') let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null function valid(value: DispatchEvidenceFile, expected?: string) { if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取') @@ -44,13 +45,30 @@ function imageFailed(event: Event) { imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。' } async function load() { - const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value] + const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])] for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) } thumbnails.value = {} - closePreview(); metadata.value = {}; error.value = null; masterBlob = null + closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false) if (!no) return + // 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。 + if (!values.length && props.masterResource) { + const resource = props.masterResource + try { + const source = await getResourcePhoto(resource.kind, resource.id) + if (current !== generation) return + if (source.photo) { + const blob = await downloadResourcePhoto(resource.kind, resource.id, source.photo.fileId) + if (current !== generation) return + const file = valid(source.photo) + if (blob.size !== file.sizeBytes || blob.type !== file.contentType) throw new DispatchInputError('档案照片内容与元数据不一致') + metadata.value[file.fileId] = file; masterBlob = { fileId: file.fileId, blob } + profilePhotoId.value = file.fileId; values.push(file.fileId); emit('available', true) + } + } catch (cause) { if (current === generation) fail(cause, '档案照片读取失败,请重试') } + } const master = props.kind === 'DRIVER' ? props.masterLicense : null const results = await Promise.allSettled(values.map(async id => { + if (id === profilePhotoId.value && metadata.value[id]) return metadata.value[id]! // 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。 if (master && master.fileId === id) { const blob = await downloadDriverLicense(master.driverId, id) @@ -89,7 +107,7 @@ async function upload(event: Event) { } function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) } async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) { - const blob = kind === 'DRIVER' && masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id) + const blob = masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id) const type = blob.type.split(';')[0]?.toLowerCase() if (type?.includes('json')) { let body: Partial<{ code: string; message: string; requestId: string; traceId: string }> @@ -115,7 +133,7 @@ async function preview(id: string) { } catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } } finally { if (current === previewGeneration) previewLoading.value = false } } -watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.masterLicense]), load, { immediate: true }) +watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true }) onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
{{ customerName(form.customerId) || editing.customerName || '—' }}