Merge origin/release/test and combine transport photo workflows
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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项验证结果;未部署、未做真机/人工截图验收。
|
||||
|
||||
@@ -3,15 +3,16 @@ 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<LoadingPhotoSlot, string> = { 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 LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; reviewRound: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
||||
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; uploadKey: 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<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
|
||||
}
|
||||
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string, reviewRound: string) {
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key); form.append('reviewRound', reviewRound)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
|
||||
}
|
||||
export async function recoverLoadingPhoto(no: string, key: string) {
|
||||
@@ -22,6 +23,6 @@ export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body:
|
||||
}
|
||||
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
|
||||
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
|
||||
if (!(blob instanceof Blob) || !(['image/jpeg', 'image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料读取失败,请重新核对')
|
||||
return blob
|
||||
}
|
||||
|
||||
@@ -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<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), 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' })
|
||||
|
||||
@@ -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<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/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 }>, 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
|
||||
|
||||
@@ -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: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
|
||||
const button = (wrapper: ReturnType<typeof mount>, 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<ReturnType<typeof getResourcePhoto>>) => 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()
|
||||
})
|
||||
|
||||
@@ -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<string[]>({ required: true })
|
||||
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
|
||||
const emit = defineEmits<{ busy: [value: boolean] }>()
|
||||
const profilePhotoId = ref<string | null>(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<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
|
||||
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
||||
const thumbnails = ref<Record<string, string>>({})
|
||||
@@ -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<string>()
|
||||
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) })
|
||||
</script>
|
||||
<template>
|
||||
@@ -125,11 +143,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
<div v-if="allIds.length" class="evidence-photo-list">
|
||||
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
|
||||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
|
||||
<p class="density-dialog-note evidence-photo-name">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
|
||||
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="evidence-photo-empty">暂无照片</div>
|
||||
<p v-if="profilePhotoId" class="density-dialog-note">已带入档案照片,保存后用于本趟排车</p>
|
||||
<figcaption>{{ label }}<InlineHelp v-if="!readonly" title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></figcaption>
|
||||
</figure>
|
||||
<template v-else>
|
||||
@@ -153,8 +172,9 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
.evidence-input { display: none; }
|
||||
.dispatch-evidence--photos { min-width: 0; display: flex; flex-direction: column; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; flex: 1; display: flex; flex-direction: column; }
|
||||
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
|
||||
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.evidence-photo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: start; gap: var(--admin-space-3); }
|
||||
.evidence-photo-item { min-width: 0; }
|
||||
.evidence-photo-name { margin: var(--admin-space-2) 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.evidence-photo-actions { justify-content: center; }
|
||||
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
|
||||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||||
|
||||
@@ -10,11 +10,11 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operato
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal<typeof api>(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() }))
|
||||
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
|
||||
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
||||
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
||||
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
|
||||
async function upload(wrapper: ReturnType<typeof setup>) {
|
||||
const input = wrapper.get('input[aria-label="装车照片 1"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], 'loading.png', { type: 'image/png' })] })
|
||||
async function upload(wrapper: ReturnType<typeof setup>, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) {
|
||||
const input = wrapper.get('input[aria-label="上传司机驾驶证"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: files })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
@@ -24,20 +24,146 @@ beforeEach(() => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] }))
|
||||
vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable'))
|
||||
})
|
||||
it('refreshes photos added or replaced by PDA without writing and notifies the parent to reload the stage', async () => {
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [photo] }))
|
||||
const wrapper = setup(); await flushPromises()
|
||||
const photos = api.loadingPhotoSlots.flatMap((slot, category) => Array.from({ length: 5 }, (_, index) => ({
|
||||
...photo, slot, fileId: `${category + 2}${index}`, fileName: `pda-${category}-${index}.png`,
|
||||
})))
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos }))
|
||||
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
|
||||
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
|
||||
expect(wrapper.text()).not.toContain(photo.fileName)
|
||||
expect(wrapper.findAll('.loading-document-heading').map(row => row.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}5/5 张`))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
|
||||
vi.mocked(api.getLoadingDocuments).mockRejectedValueOnce(new Error('刷新失败'))
|
||||
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
|
||||
const table = wrapper.get('table[aria-label="装车照片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['装车照片 1', '装车照片 2', '装车照片 3', '装车照片 4', '装车照片 5'])
|
||||
const list = wrapper.get('[aria-label="装车照片"]')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(list.findAll('h3').map(heading => heading.text())).toEqual(Object.values(api.loadingPhotoLabels))
|
||||
expect(list.findAll('.photo-count').map(count => count.text())).toEqual(Array(5).fill('0/5 张'))
|
||||
expect(list.findAll('.photo-empty')).toHaveLength(5)
|
||||
await upload(wrapper)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String), '1')
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2].uploadKey).toBe(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![3])
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('图片读取失败')
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
expect(list.get('.loading-photo-row').attributes('aria-label')).toBe('司机驾驶证第1张')
|
||||
expect(list.text()).not.toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('stops a batch when re-review changes the source during upload instead of rebasing old files', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
vi.mocked(api.uploadLoadingPhoto).mockImplementationOnce(async () => {
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '8', dispatchRecordVersion: '12', reviewRound: '2' }))
|
||||
return photo
|
||||
})
|
||||
await upload(wrapper, [new File(['a'], 'old-driver.png', { type: 'image/png' }), new File(['b'], 'old-driver-2.png', { type: 'image/png' })])
|
||||
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![4]).toBe('1')
|
||||
expect(wrapper.text()).toContain('审核轮次已变化')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows an imported PDF license as an authenticated document and accepts an exact PDF replacement', async () => {
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:license')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [license] }))
|
||||
vi.mocked(api.readLoadingPhoto).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' }))
|
||||
const wrapper = setup()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.get('a').attributes('href')).toBe('blob:license')
|
||||
expect(wrapper.findComponent({ name: 'ElImage' }).exists()).toBe(false)
|
||||
const input = wrapper.get('input[aria-label="更换司机驾驶证第1张"]')
|
||||
expect(input.attributes('accept')).toContain('application/pdf')
|
||||
expect(wrapper.get('input[aria-label="上传车牌照片"]').attributes('accept')).not.toContain('application/pdf')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['%PDF-1.4'], 'new.pdf', { type: 'application/pdf' })] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ replaceFileId: '101' }))
|
||||
} finally { wrapper.unmount(); vi.restoreAllMocks() }
|
||||
})
|
||||
it('shows every photo in a category, stops at five and replaces the selected image only', async () => {
|
||||
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png`, savedAt: `2026-10-26T11:49:0${index}.978960362Z` }))
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos }))
|
||||
const replacement = { ...photo, fileId: '999' }
|
||||
vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement)
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) }))
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined()
|
||||
const rows = wrapper.findAll('.loading-photo-row')
|
||||
expect(rows).toHaveLength(5)
|
||||
rows.forEach((row, index) => {
|
||||
expect(row.attributes('aria-label')).toBe(`司机驾驶证第${index + 1}张`)
|
||||
expect(row.get('.photo-time').text()).toMatch(new RegExp(`保存时间:2026-10-26 \\d{2}:49:0${index}$`))
|
||||
expect(row.findAll('input[type="file"]')).toHaveLength(1)
|
||||
})
|
||||
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
|
||||
const input = rows[2]!.get('input[aria-label="更换司机驾驶证第3张"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' }))
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(5)
|
||||
expect(wrapper.findAll('.loading-photo-row')[0]!.get('.photo-time').text()).toMatch(/:49:00$/)
|
||||
expect(wrapper.findAll('.loading-photo-row')[2]!.get('.photo-time').text()).toBe('保存时间:—')
|
||||
expect(wrapper.findAll('.loading-photo-row')[4]!.get('.photo-time').text()).toMatch(/:49:04$/)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('saves a multiple selection with fresh versions and notifies the parent once', async () => {
|
||||
let latest = data(), id = 200
|
||||
vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest)
|
||||
vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) }))
|
||||
vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => {
|
||||
latest = data({ recordVersion: String(Number(body.recordVersion) + 1), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
|
||||
})
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('multiple')).toBeDefined()
|
||||
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(3)
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls.map(call => call[2].recordVersion)).toEqual(['0', '1', '2'])
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).toContain('3/5 张')
|
||||
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(3)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('stops a batch after an unknown binding result and displays the server snapshot', async () => {
|
||||
let latest = data(), id = 200, binds = 0
|
||||
vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest)
|
||||
vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) }))
|
||||
vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => {
|
||||
latest = data({ recordVersion: String(++binds), photos: [...latest.photos, { ...photo, fileId: body.fileId }] })
|
||||
if (binds === 2) throw new Error('保存响应超时')
|
||||
return latest
|
||||
})
|
||||
const wrapper = setup(); await flushPromises()
|
||||
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('2/5 张'); expect(wrapper.text()).toContain('已读取最新资料')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('requires a successful refresh after both binding and reconciliation fail', async () => {
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValueOnce(data()).mockResolvedValueOnce(data()).mockRejectedValueOnce(new Error('读取失败')).mockResolvedValue(data())
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('保存超时'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
expect(wrapper.text()).toContain('保存状态尚未核实')
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
expect(wrapper.find('input[aria-label="上传司机驾驶证"]').exists()).toBe(true)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, loadingPhotoLabels, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const needsRefresh = ref(false)
|
||||
const images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true && !needsRefresh.value)
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
|
||||
function accept(value: LoadingDocuments) {
|
||||
ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致')
|
||||
const current = new Set(value.photos.map(photo => photo.fileId))
|
||||
for (const [id, url] of Object.entries(images.value)) if (!current.has(id)) { URL.revokeObjectURL(url); delete images.value[id] }
|
||||
snapshot.value = value; needsRefresh.value = false
|
||||
}
|
||||
async function previews() {
|
||||
const photos = snapshot.value?.photos || []
|
||||
await Promise.allSettled(photos.map(async photo => {
|
||||
@@ -29,58 +36,115 @@ async function previews() {
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
|
||||
try { accept(await getLoadingDocuments(number)); await previews(); return true }
|
||||
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
async function refresh() { if (await load()) emit('saved') }
|
||||
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
|
||||
function savedTime(value?: string | null) {
|
||||
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
|
||||
}
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) {
|
||||
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
|
||||
if (!files.length || busy.value || !editable.value) return
|
||||
const reviewRound = snapshot.value!.reviewRound
|
||||
const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length
|
||||
if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return }
|
||||
if (files.some(file => !(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return }
|
||||
busy.value = true; error.value = null
|
||||
let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false
|
||||
try {
|
||||
ensureScope()
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getLoadingDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
await previews()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
for (const file of files) {
|
||||
const uploadKey = crypto.randomUUID()
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, uploadKey, reviewRound); ensureScope()
|
||||
const latest = await getLoadingDocuments(number); accept(latest)
|
||||
if (latest.reviewRound !== reviewRound) { reconciled = true; roundChanged = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
try {
|
||||
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }))
|
||||
} catch (cause) {
|
||||
// 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。
|
||||
bindingFailed = true
|
||||
needsRefresh.value = true
|
||||
try {
|
||||
const current = await getLoadingDocuments(number); accept(current)
|
||||
reconciled = current.recordVersion !== latest.recordVersion || current.dispatchRecordVersion !== latest.dispatchRecordVersion || current.stage !== latest.stage
|
||||
await previews()
|
||||
} catch { /* 保留原错误与刷新入口。 */ }
|
||||
throw cause
|
||||
}
|
||||
saved++; await previews()
|
||||
}
|
||||
ElMessage.success(`已保存${saved}张照片`)
|
||||
} catch (cause) {
|
||||
if (active) error.value = getApiErrorDetails(cause, roundChanged ? '审核轮次已变化,请核对新车辆、司机后重新选择资料' : bindingFailed
|
||||
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
|
||||
: '图片上传失败,已保存照片保留,请重新选择图片')
|
||||
}
|
||||
finally { busy.value = false }
|
||||
if (active && (saved || reconciled)) emit('saved')
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
|
||||
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
||||
<div v-loading="busy" class="loading-documents">
|
||||
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
|
||||
<p>司机驾驶证、车牌照片在理货审核通过后从本趟资料自动带入,可补充或更换;驾驶证支持 PDF。</p>
|
||||
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
|
||||
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>装车照片 {{ index + 1 }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
||||
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<div class="loading-document-list" aria-label="装车照片">
|
||||
<section v-for="slot in loadingPhotoSlots" :key="slot" class="loading-document-category" :aria-label="loadingPhotoLabels[slot]">
|
||||
<header class="loading-document-heading">
|
||||
<h3>{{ loadingPhotoLabels[slot] }}</h3>
|
||||
<span class="photo-count">{{ photosFor(slot).length }}/5 张</span>
|
||||
<label v-if="editable" class="photo-upload category-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">
|
||||
{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传资料' }}
|
||||
<input type="file" multiple :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" />
|
||||
</label>
|
||||
</header>
|
||||
<ol v-if="photosFor(slot).length" class="loading-photo-list">
|
||||
<li v-for="(photo, index) in photosFor(slot)" :key="photo.fileId" class="loading-photo-row" :aria-label="`${loadingPhotoLabels[slot]}第${index + 1}张`">
|
||||
<span class="photo-index">{{ index + 1 }}</span>
|
||||
<div class="photo-preview">
|
||||
<a v-if="images[photo.fileId] && photo.contentType === 'application/pdf'" :href="images[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
|
||||
<el-image v-else-if="images[photo.fileId]" :src="images[photo.fileId]" :alt="`${loadingPhotoLabels[slot]}第${index + 1}张`" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||
</div>
|
||||
<span class="photo-time">保存时间:{{ savedTime(photo.savedAt) }}</span>
|
||||
<label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">
|
||||
更换
|
||||
<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${loadingPhotoLabels[slot]}第${index + 1}张`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" />
|
||||
</label>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="photo-empty">未上传</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.loading-documents-table { table-layout: fixed; }
|
||||
.loading-documents-table td { overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 120px; }
|
||||
.photo-preview { width: 140px; }
|
||||
.photo-time { width: 190px; }
|
||||
.loading-photo-image { display: block; width: 100px; height: 64px; }
|
||||
.loading-documents { display: flex; flex-direction: column; gap: 12px; }
|
||||
.loading-documents > p { margin: 0; }
|
||||
.loading-document-list { border: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.loading-document-category + .loading-document-category { border-top: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.loading-document-heading { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); }
|
||||
.loading-document-heading h3 { margin: 0; font-size: 14px; }
|
||||
.photo-count, .photo-time, .photo-index, .photo-empty { color: var(--el-text-color-secondary); }
|
||||
.category-upload { margin-left: auto; }
|
||||
.loading-photo-list { display: flex; flex-direction: column; margin: 0; padding: 0 12px; list-style: none; }
|
||||
.loading-photo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 8px 0; }
|
||||
.loading-photo-row + .loading-photo-row { border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.photo-index { width: 20px; text-align: center; }
|
||||
.photo-preview { display: flex; align-items: center; flex: 0 0 160px; min-height: 96px; }
|
||||
.loading-photo-image { display: block; width: 160px; height: 96px; }
|
||||
.photo-time { font-variant-numeric: tabular-nums; }
|
||||
.photo-empty { margin: 0; padding: 12px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
|
||||
@@ -3,9 +3,10 @@ import { ApiResponseError } from '@/shared/api/http'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
@@ -102,6 +103,14 @@ it('refuses a route that drops an authoritative destination', async () => {
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
||||
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
||||
})
|
||||
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('requires saved vehicle and driver identities for %s review submission', async taskType => {
|
||||
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', pickups: [] } as unknown as Awaited<ReturnType<typeof transportApi.getTransport>>)
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW', true, { taskType })
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('车辆信息尚未维护完整')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports A0409 and immediately releases the form without a forced readback', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
||||
@@ -292,3 +301,45 @@ it('saves a cleared unavailable master vehicle as an empty draft resource', asyn
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not silently discard %s files when the master resource is missing', async kind => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
||||
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
||||
editor.driver.mode = 'MASTER'; editor.driver.driverId = null
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === kind)!
|
||||
expect(card.get('.evidence-upload-button').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('请先在“排车信息”')
|
||||
// A pending reference must also be guarded at save time, rather than being converted to null.
|
||||
card.vm.$emit('update:modelValue', ['301'])
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(card.props('modelValue')).toEqual(['301'])
|
||||
expect(wrapper.text()).toContain(kind === 'VEHICLE' ? '请先选择车辆' : '请先选择司机')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['MASTER', 'EXTERNAL'] as const)('keeps all three %s photo references after saving, reopening and saving again', async mode => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
||||
editor.vehicle.mode = mode; editor.vehicle.vehicleId = mode === 'MASTER' ? '11' : null
|
||||
editor.driver.mode = mode; editor.driver.driverId = mode === 'MASTER' ? '12' : null
|
||||
const expected = { VEHICLE: ['301'], DRIVER_PHOTO: ['302'], DRIVER: ['303'] }
|
||||
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) card.vm.$emit('update:modelValue', expected[card.props('kind') as keyof typeof expected])
|
||||
let saved: DispatchDetail | undefined
|
||||
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => {
|
||||
saved = detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: JSON.parse(JSON.stringify({ vehicle: body.vehicle, driver: body.driver, maintenance: body.maintenance })) })
|
||||
vi.mocked(api.getDispatch).mockImplementation(async () => JSON.parse(JSON.stringify(saved)))
|
||||
return command('EDIT', body.idempotencyKey)
|
||||
})
|
||||
await state.submit(); await flushPromises()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) expect(card.props('modelValue')).toEqual(expected[card.props('kind') as keyof typeof expected])
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toMatchObject({ vehicle: { evidenceFileIds: ['301'] }, driver: { evidence: { photoFileIds: ['302'], fileIds: ['303'] } } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -41,6 +41,8 @@ const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; pa
|
||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const missingVehicle = computed(() => vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId)
|
||||
const missingDriver = computed(() => driver.value.mode === 'MASTER' && !driver.value.driverId)
|
||||
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
|
||||
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
|
||||
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
|
||||
@@ -134,7 +136,10 @@ function payload(): object {
|
||||
case 'MARK_EXCEPTION':
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT':
|
||||
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
|
||||
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
|
||||
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -148,7 +153,7 @@ function payload(): object {
|
||||
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
|
||||
}
|
||||
case 'SUBMIT_REVIEW':
|
||||
if (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId) {
|
||||
if (['PICKUP', 'LOCAL_TRUCK'].includes(detail.value.taskType || '') && (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId)) {
|
||||
throw new DispatchInputError('车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核')
|
||||
}
|
||||
return { ...common, remark: null }
|
||||
@@ -263,8 +268,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -279,7 +284,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<tr v-else><th scope="row">起运地</th><td>{{ detail.dispatchInfo.route?.split(' → ')[0] || '—' }}</td><th scope="row">目的地</th><td colspan="3">{{ detail.dispatchInfo.route?.split(' → ').slice(1).join(' → ') || '—' }}</td></tr>
|
||||
<tr><th scope="row">计划发车</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td><th scope="row">货量</th><td>{{ detail.counts.palletCount }} 托 / {{ detail.counts.packageCount }} 箱</td></tr>
|
||||
</tbody></table>
|
||||
<el-alert v-if="!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType || '') && (!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId)" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
@@ -328,7 +333,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; gap: var(--admin-space-4); }
|
||||
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
|
||||
.workflow-review-actions > .el-button { margin-left: 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import InboundShortageConfirmation from './InboundShortageConfirmation.vue'
|
||||
|
||||
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access }))
|
||||
vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() }))
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<div v-if="modelValue"><slot /><button data-confirm :disabled="confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认并按实收入库</button></div>' }
|
||||
function detail(overrides: Partial<InboundPlanDetailDto> = {}): InboundPlanDetailDto {
|
||||
return { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
|
||||
orderType: 'FIRST_LEG', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
|
||||
lineCount: 1, palletCount: 2, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0,
|
||||
lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 2 }],
|
||||
pallets: [], allocations: [], ...overrides }
|
||||
}
|
||||
function render(value = detail()) {
|
||||
return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } })
|
||||
}
|
||||
describe('入库短少确认', () => {
|
||||
beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) })
|
||||
it('requires a reason and submits actual counts without overwriting the expected count', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.text()).toContain('21')
|
||||
expect(wrapper.text()).toContain('20')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('textarea').setValue('现场清点短少1箱')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String))
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => {
|
||||
vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化'))
|
||||
const wrapper = render()
|
||||
await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱')
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱')
|
||||
await wrapper.setProps({ detail: detail({ version: 5 }) })
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
const calls = vi.mocked(confirmInboundShortage).mock.calls
|
||||
expect(calls[1]?.[1].version).toBe(5)
|
||||
expect(calls[1]?.[2]).not.toBe(calls[0]?.[2])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('hides the action without edit permission', () => {
|
||||
access.can.mockReturnValue(false)
|
||||
const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => {
|
||||
const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import { overseasWarehousePermissions } from '../permissions'
|
||||
|
||||
const props = defineProps<{ detail: InboundPlanDetailDto }>()
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const open = ref(false)
|
||||
const saving = ref(false)
|
||||
const reason = ref('')
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const expected = computed(() => props.detail.expectedPackageCount)
|
||||
const received = computed(() => props.detail.pickedPackageCount)
|
||||
const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0))
|
||||
const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit)
|
||||
&& props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null
|
||||
&& props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0
|
||||
&& props.detail.lines.every(line => !!line.fbaWarehouseCode)
|
||||
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
|
||||
|
||||
function show() {
|
||||
if (!eligible.value || saving.value) return
|
||||
reason.value = ''; error.value = null; open.value = true
|
||||
}
|
||||
async function confirm() {
|
||||
if (!open.value || saving.value || !eligible.value || !reason.value.trim()
|
||||
|| props.detail.version == null || expected.value == null || received.value == null) return
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
await confirmInboundShortage(props.detail.planNo, {
|
||||
customerId: props.detail.customerId, version: props.detail.version,
|
||||
expectedPackageCount: expected.value, receivedPackageCount: received.value, reason: reason.value.trim(),
|
||||
}, crypto.randomUUID())
|
||||
ElMessage.success(`已确认短少,${received.value} 箱实收已入库`)
|
||||
open.value = false
|
||||
emit('refresh')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '短少确认失败,请核对最新收货记录后重试')
|
||||
emit('refresh')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认短少入库</el-button>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="确认短少入库" width="560px"
|
||||
confirm-text="确认并按实收入库" :confirm-loading="saving" :confirm-disabled="!eligible || !reason.trim()" @confirm="confirm">
|
||||
<div class="density-dialog-sections">
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">入库单号</th><td>{{ detail.planNo }}</td></tr>
|
||||
<tr><th scope="row">应收箱数</th><td>{{ expected }}</td></tr>
|
||||
<tr><th scope="row">实收入库箱数</th><td>{{ received }}</td></tr>
|
||||
<tr><th scope="row">确认短少箱数</th><td>{{ shortage }}</td></tr>
|
||||
</tbody></table>
|
||||
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving">
|
||||
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="短少确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="error" compact title="短少确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import TransportExecutionPanel from './TransportExecutionPanel.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import { assignTransport, getTransport, type TransportDetail } from '../api/transportTasks'
|
||||
import { getDispatchEvidence, getDispatchEvidenceContent } from '../api/dispatches'
|
||||
import { getDriver, getResourcePhoto, downloadResourcePhoto, downloadDriverLicense } from '@/modules/overseas-master-data'
|
||||
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn(), downloadDriverLicense: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货' } }))
|
||||
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||
|
||||
const facts = (): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', dispatchRecordVersion: 9, execution: { trip: { status: 'PLANNED', recordVersion: 4, vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, status: 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
|
||||
const photo = (fileId: string) => ({ fileId, fileName: `${fileId}.jpg`, contentType: 'image/jpeg', sizeBytes: 5 })
|
||||
const blob = () => new Blob(['photo'], { type: 'image/jpeg' })
|
||||
const mountPanel = () => mount(TransportExecutionPanel, { props: { dispatchNo: 'PC1', editResources: true }, global: { plugins: [ElementPlus] } })
|
||||
const saveButton = (wrapper: ReturnType<typeof mountPanel>) => wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:resource-photo')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
vi.mocked(getTransport).mockImplementation(async () => facts())
|
||||
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
|
||||
vi.mocked(getResourcePhoto).mockImplementation(async kind => ({ recordVersion: '1', photo: photo(kind === 'VEHICLE' ? '91' : '92') }))
|
||||
vi.mocked(downloadResourcePhoto).mockImplementation(async () => blob())
|
||||
vi.mocked(downloadDriverLicense).mockImplementation(async () => blob())
|
||||
vi.mocked(getDispatchEvidence).mockImplementation(async (_no, _kind, id) => photo(id))
|
||||
vi.mocked(getDispatchEvidenceContent).mockImplementation(async () => blob())
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('keeps automatically loaded photos available after refreshing the open form', async () => {
|
||||
const wrapper = mountPanel()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(getResourcePhoto).toHaveBeenCalledTimes(2)
|
||||
await wrapper.get('[aria-label="刷新运输任务"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.evidence-photo-preview')).toHaveLength(3)
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
vi.mocked(assignTransport).mockResolvedValue(facts())
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ reviewPhotos: { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: [] } }))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('loads local truck vehicle and driver archives while retaining the trip license upload requirement', async () => {
|
||||
const truck = facts()
|
||||
truck.taskType = 'LOCAL_TRUCK'
|
||||
truck.pickups = []
|
||||
truck.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(truck)
|
||||
vi.mocked(assignTransport).mockResolvedValue(truck)
|
||||
const wrapper = mountPanel()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(getResourcePhoto).toHaveBeenCalledWith('VEHICLE', '1')
|
||||
expect(getResourcePhoto).toHaveBeenCalledWith('DRIVER', '2')
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(cards[2]!.props('masterLicense')).toBeNull()
|
||||
cards[2]!.vm.$emit('update:modelValue', ['103'])
|
||||
await flushPromises()
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({
|
||||
dispatchRecordVersion: 9, masterLicenseFileId: null,
|
||||
reviewPhotos: { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['103'] },
|
||||
}))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('resubmits saved snapshots when reopening maintenance even if archive photos change', async () => {
|
||||
const saved = facts()
|
||||
saved.execution.trip.recordVersion = 5
|
||||
saved.dispatchRecordVersion = 10
|
||||
saved.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['101'] }, driver: { driverId: '2', evidence: { photoFileIds: ['102'], fileIds: ['33'], masterLicenseFileId: '33' } } } as TransportDetail['resources']
|
||||
vi.mocked(assignTransport).mockResolvedValue(saved)
|
||||
const wrapper = mountPanel()
|
||||
try {
|
||||
await flushPromises()
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(assignTransport).toHaveBeenCalledTimes(1)
|
||||
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo: photo('999') })
|
||||
await wrapper.findAll('button').find(button => button.text() === '维护车辆信息')!.trigger('click')
|
||||
await flushPromises()
|
||||
const reopened = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(reopened[0]!.props('modelValue')).toEqual(['101'])
|
||||
expect(reopened[1]!.props('modelValue')).toEqual(['102'])
|
||||
expect(getResourcePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(getDispatchEvidence).toHaveBeenCalledWith('PC1', 'VEHICLE', '101')
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(assignTransport).toHaveBeenLastCalledWith('PC1', expect.objectContaining({ recordVersion: 5, dispatchRecordVersion: 10, reviewPhotos: { vehicleFileIds: ['101'], driverPhotoFileIds: ['102'], licenseFileIds: [] } }))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it.each([0, 1])('requires a new photo when resource selector %s changes to an archive without a photo', async index => {
|
||||
const wrapper = mountPanel()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[index]!.vm.$emit('update:modelValue', '4')
|
||||
await flushPromises()
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.findAllComponents(DispatchEvidenceFiles)[index]!.props('modelValue')).toEqual([])
|
||||
expect(getResourcePhoto).toHaveBeenLastCalledWith(index === 0 ? 'VEHICLE' : 'DRIVER', '4')
|
||||
await saveButton(wrapper).trigger('click')
|
||||
expect(assignTransport).not.toHaveBeenCalled()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
@@ -23,9 +23,10 @@ const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), ve
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
const vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
|
||||
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
|
||||
const masterPhotoAvailable = reactive({ vehicle: false, driver: false })
|
||||
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
|
||||
const activeMasterLicense = computed(() => detail.value?.taskType === 'PICKUP' && !licensePhotos.value.length ? masterLicense.value : null)
|
||||
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!((vehiclePhotos.value.length || masterPhotoAvailable.vehicle) && (driverPhotos.value.length || masterPhotoAvailable.driver) && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([])
|
||||
const truckPickupEvidence = ref<PickupEvidence[]>([]), truckDeliveryEvidence = ref<PickupEvidence[]>([])
|
||||
const truckPreviewUrls = ref<Record<string, string>>({})
|
||||
@@ -74,7 +75,10 @@ function restoreVehiclePhotos() {
|
||||
const saved = detail.value?.resources?.vehicle
|
||||
vehiclePhotos.value = saved?.vehicleId === vehicleId.value ? [...saved.evidenceFileIds] : []
|
||||
}
|
||||
watch(vehicleId, restoreVehiclePhotos, { flush: 'sync' })
|
||||
// 同一资源刷新沿用照片组件的可用状态;切换资源时立即清空,等待新照片确认。
|
||||
watch(vehicleId, () => { masterPhotoAvailable.vehicle = false }, { flush: 'sync' })
|
||||
watch(driverId, () => { masterPhotoAvailable.driver = false }, { flush: 'sync' })
|
||||
watch(() => [vehicleId.value, resourceFormOpen.value, detail.value] as const, restoreVehiclePhotos, { flush: 'sync' })
|
||||
/** 切换司机后重读其主档驾驶证,迟到响应不得覆盖新司机或新单据。 */
|
||||
watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, async ([id, open], _, onCleanup) => {
|
||||
let current = true
|
||||
@@ -214,8 +218,8 @@ onBeforeUnmount(clearTruckPreviewUrls)
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
|
||||
<p class="transport-photo-hint workbench-meta">请补齐车辆图片、司机照片和驾驶证照片;保存后供调度员和理货员审核。</p>
|
||||
</section>
|
||||
|
||||
@@ -129,6 +129,7 @@ describe('last-mile order presentation adapter', () => {
|
||||
['SIGNED', '已签收'],
|
||||
['PICKING', '拣货中'],
|
||||
['PARTIAL_INBOUND', '部分入库'],
|
||||
['PENDING_RECONCILIATION', '待差异核对'],
|
||||
['COMPLETED', '全部入库'],
|
||||
])('maps inbound plan status %s to %s', (status, expected) => {
|
||||
expect(inboundPlanStatusLabel(status)).toBe(expected)
|
||||
|
||||
@@ -46,6 +46,7 @@ const inboundPlanStatusLabels: Record<string, string> = {
|
||||
PDA_PENDING: '待入库',
|
||||
SIGNED: '已签收',
|
||||
PICKING: '拣货中',
|
||||
PENDING_RECONCILIATION: '待差异核对',
|
||||
PARTIAL_INBOUND: '部分入库',
|
||||
COMPLETED: '全部入库',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { getInboundPlan } from '../api/inboundPlans'
|
||||
import InboundOrderDetailView from './InboundOrderDetailView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } }))
|
||||
|
||||
it('provides the scoped plan facts and reloads after shortage confirmation', async () => {
|
||||
const detail = { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
|
||||
status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
|
||||
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] }
|
||||
vi.mocked(getInboundPlan).mockResolvedValue(detail)
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, BusinessDocumentFees: true } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail)
|
||||
expect(wrapper.text()).toContain('待差异核对')
|
||||
await wrapper.get('[data-shortage]').trigger('click'); await flushPromises()
|
||||
expect(getInboundPlan).toHaveBeenCalledTimes(2)
|
||||
expect(getInboundPlan).toHaveBeenLastCalledWith('IP-1', '9007199254741001')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -8,6 +8,7 @@ const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { custom
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
|
||||
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
|
||||
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
|
||||
} }))
|
||||
@@ -22,7 +23,7 @@ describe('InboundOrderDetailView fees', () => {
|
||||
})
|
||||
|
||||
it('uses the loaded inbound plan identity without substituting its source order', async () => {
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, InboundShortageConfirmation: true } } })
|
||||
await flushPromises()
|
||||
expect(getInboundPlan).toHaveBeenCalledWith('PLAN-1', '9007199254741001')
|
||||
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
|
||||
@@ -34,7 +35,7 @@ describe('InboundOrderDetailView fees', () => {
|
||||
|
||||
it('does not expose fee operations when the customer scope is absent', async () => {
|
||||
route.query = {}
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, InboundShortageConfirmation: true } } })
|
||||
await flushPromises()
|
||||
expect(getInboundPlan).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
|
||||
|
||||
@@ -15,6 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
@@ -98,7 +99,7 @@ onMounted(load)
|
||||
<template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
|
||||
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
|
||||
<section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
|
||||
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
|
||||
|
||||
@@ -42,7 +42,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }, { label: '待差异核对', value: 'PENDING_RECONCILIATION' }] },
|
||||
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
|
||||
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
|
||||
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }, { label: '多批预约', value: 'MULTIPLE' }] },
|
||||
|
||||
@@ -157,10 +157,10 @@ async function mountView() {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function openPending() {
|
||||
async function openAfterDeparture() {
|
||||
const wrapper = await mountView()
|
||||
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')
|
||||
await pendingTab!.trigger('click')
|
||||
const departedTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船后')
|
||||
await departedTab!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
@@ -308,9 +308,13 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'FIRST_LEG', sailingStage: stage, detained: false }))
|
||||
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false)
|
||||
await button(wrapper, '导出订单')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0])
|
||||
if (stage === 'BEFORE_DEPARTURE') {
|
||||
expect(button(wrapper, '导出订单')).toBeUndefined()
|
||||
} else {
|
||||
await button(wrapper, '导出订单')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0])
|
||||
}
|
||||
}
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -359,6 +363,18 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(button(wrapper, '高级筛选')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([null, 'ESTIMATED_DEPARTURE'])('keeps a first-leg order without actual departure read-only: %s', async containerNode => {
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'before-1', customerId, orderNo: 'LM-BEFORE', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = await mountView()
|
||||
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
|
||||
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(['查看'])
|
||||
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
|
||||
expect((wrapper.vm as unknown as { selectedRows: object[] }).selectedRows).toEqual([expect.objectContaining({ orderTypeCode: 'FIRST_LEG', containerNode: containerNode || '—' })])
|
||||
expect(wrapper.find('.data-table__business-actions').exists()).toBe(false)
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
expect(transferLastMileOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the original carton count and adds only the signed difference in the list', async () => {
|
||||
canAccess.mockReturnValue(false)
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
@@ -381,7 +397,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库', '修改节点'], false],
|
||||
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
|
||||
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = await mountView()
|
||||
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
|
||||
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
|
||||
@@ -507,7 +523,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => {
|
||||
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false,
|
||||
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false,
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
|
||||
const wrapper = await mountView()
|
||||
@@ -520,7 +536,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('keeps release hold available for an already detained order', async () => {
|
||||
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true,
|
||||
id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: true,
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
|
||||
const wrapper = await mountView()
|
||||
@@ -533,7 +549,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('removes generation and hold actions after an inbound plan has been submitted', async () => {
|
||||
canAccess.mockReturnValue(true)
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false,
|
||||
id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false,
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
|
||||
const wrapper = await mountView()
|
||||
@@ -546,8 +562,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the original pending order table and does not render a view switch or container table', async () => {
|
||||
const wrapper = await openPending()
|
||||
it('keeps after-departure order actions in the existing table without a container view switch', async () => {
|
||||
const wrapper = await openAfterDeparture()
|
||||
expect(wrapper.text()).toContain('尾程单号')
|
||||
expect(wrapper.text()).toContain('海外仓')
|
||||
expect(wrapper.text()).toContain('转仓')
|
||||
@@ -556,7 +572,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(wrapper.text()).not.toContain('按订单')
|
||||
expect(wrapper.text()).not.toContain('待分配整柜')
|
||||
expect(wrapper.text()).not.toContain('柜子工作台')
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE' }))
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'AFTER_DEPARTURE' }))
|
||||
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -613,7 +629,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -633,7 +649,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -655,7 +671,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('blocks a held order from generating its inbound plan', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow({
|
||||
detained: true,
|
||||
orderTypeCode: 'ONE_CLICK_DROPSHIP',
|
||||
@@ -676,7 +692,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'],
|
||||
])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival])
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' }))
|
||||
|
||||
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
|
||||
@@ -697,7 +713,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
{ arrivals: [{ ...arrivalTime, version: -1 }] },
|
||||
])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals)
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openArrivalPlan(wrapper)
|
||||
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
@@ -708,7 +724,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it('shows arrival read failures and does not reuse the list timestamp', async () => {
|
||||
vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503))
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.text()).toContain('到仓来源暂不可读')
|
||||
@@ -721,7 +737,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => {
|
||||
let resolveOld!: (value: typeof arrivalTime[]) => void
|
||||
vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
@@ -750,7 +766,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
}],
|
||||
}],
|
||||
} as never)
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })])
|
||||
await button(wrapper, '入库')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -768,7 +784,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper)
|
||||
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
|
||||
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
|
||||
@@ -781,7 +797,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper)
|
||||
await chooseContainer(wrapper)
|
||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
|
||||
@@ -807,7 +823,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError(
|
||||
'柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`,
|
||||
))
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper)
|
||||
await chooseContainer(wrapper)
|
||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
|
||||
@@ -826,7 +842,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
if (id === bookingId) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}) as never)
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper)
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
const containerSelect = selects[selects.length - 1]
|
||||
@@ -848,7 +864,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(convertLastMileContainerToInboundPlans)
|
||||
.mockRejectedValueOnce(new Error('network uncertain'))
|
||||
.mockResolvedValueOnce([{ planNo: 'LIP-RETRY', pallets: [{ id: 'PALLET-R', palletNo: 'PLT-RETRY' }] }] as never)
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper)
|
||||
await chooseContainer(wrapper)
|
||||
const findConfirm = () => [...document.body.querySelectorAll('button')]
|
||||
@@ -931,7 +947,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
.mockResolvedValueOnce(resourceCommittedPreview)
|
||||
.mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never)
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
cabinetDialog(wrapper).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
@@ -955,7 +971,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it('keeps ordinary zero-pending previews without resourceRetry disabled in both UI and handler', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
expect(cabinetDialog(wrapper).props('confirmText')).toBe('确认生成')
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
@@ -964,7 +980,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it('uses the visible missing-container label in the preview tooltip without exposing the booking ID', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, containerNo: null } as never)
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const identity = document.body.querySelector('.container-preview__identity')!
|
||||
expect(identity.textContent).toBe('未录入货柜号')
|
||||
expect(identity.getAttribute('title')).toBe('未录入货柜号')
|
||||
@@ -973,7 +989,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it('keeps server readiness blocks effective even for a resource retry', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 })
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
@@ -982,7 +998,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('guards resource retry at the handler when the caller lacks create permission', async () => {
|
||||
canAccess.mockImplementation(code => code !== 'platform:overseas-warehouse:inbound-order:create')
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview)
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
const view = wrapper.vm as unknown as { openContainerConversion(): void }
|
||||
view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
@@ -995,7 +1011,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
let resolveOptions!: (value: Awaited<ReturnType<typeof listContainerPlanProgress>>) => void
|
||||
vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve }))
|
||||
vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve }))
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
@@ -1011,7 +1027,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never))
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview)
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError('资源确认失败', 'S0503', 503))
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(cabinetDialog(wrapper).props('modelValue')).toBe(true)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
|
||||
@@ -1233,7 +1249,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -1257,7 +1273,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it('clears all selected warehouse facts when the FBA candidate is cleared', async () => {
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -1273,7 +1289,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it('renders an explicit empty state when there are no active FBA candidates', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -1285,7 +1301,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
|
||||
'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba',
|
||||
))
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -1355,21 +1371,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
|
||||
})
|
||||
it('renders all eight node times and submits the complete set with zero version', async () => {
|
||||
it('edits only customs nodes while preserving hidden node facts and zero version', async () => {
|
||||
vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 })
|
||||
const wrapper = await mountView()
|
||||
const row = selectedRow()
|
||||
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(row)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))!
|
||||
expect(dialog.findAll('.manual-nodes-editor tbody tr')).toHaveLength(8)
|
||||
expect(dialog.findAll('.manual-nodes-editor tbody tr').map(row => row.find('th').text())).toEqual(['国内报关完成', '国外清关完成'])
|
||||
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
|
||||
expect(dates).toHaveLength(8)
|
||||
expect(dates).toHaveLength(2)
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-20T08:00:00')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateLastMileOrderManualNodes).toHaveBeenCalledWith(row.id, 0, [
|
||||
{ nodeCode: 'DOMESTIC_CUSTOMS_COMPLETED', nodeAt: '2026-10-20T08:00:00' },
|
||||
{ nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: null },
|
||||
{ nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' },
|
||||
{ nodeCode: 'ESTIMATED_ARRIVAL', nodeAt: null }, { nodeCode: 'OVERSEAS_CUSTOMS_COMPLETED', nodeAt: null },
|
||||
{ nodeCode: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', nodeAt: null }, { nodeCode: 'PICKUP_APPOINTED', nodeAt: null }, { nodeCode: 'UNLOADED', nodeAt: null },
|
||||
], expect.stringContaining('last-mile-manual-nodes-'))
|
||||
@@ -1381,7 +1398,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
groups: Array.from({ length: 13 }, (_, index) => ({ ...group, destinationWarehouseCode: 'FBA-' + index, orderCount: 1, pendingOrderCount: 1, pendingPalletCount: 1, sourcePalletCount: 1, supplementPalletCount: 0,
|
||||
orders: [{ ...group.orders[0]!, orderId: 'order-' + index, businessOrderNo: '业务单-' + index, sourcePalletCount: 1, targetPalletCount: 1, supplementPalletCount: 0 }] })),
|
||||
})
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const dialog = cabinetDialog(wrapper)
|
||||
expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13)
|
||||
@@ -1394,7 +1411,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('keeps transfer drafts locked during submission and editable after rejection', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(transferLastMileOrders).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
|
||||
const wrapper = await openPending()
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))!
|
||||
|
||||
@@ -143,6 +143,7 @@ const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
|
||||
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
|
||||
{ code: 'UNLOADED', label: '卸柜' },
|
||||
]
|
||||
const visibleManualNodeDefinitions = manualNodeDefinitions.filter(({ code }) => ['DOMESTIC_CUSTOMS_COMPLETED', 'OVERSEAS_CUSTOMS_COMPLETED'].includes(code))
|
||||
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
|
||||
const transferOpen = ref(false)
|
||||
const transferWarehouseLoading = ref(false)
|
||||
@@ -1330,7 +1331,6 @@ onMounted(() => { void load() })
|
||||
</AppDialog>
|
||||
<AppDialog class="inbound-plan-dialog admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(1160px, calc(100vw - 48px))" top="6vh" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<div class="inbound-plan-content">
|
||||
<el-alert title="保存后生成入库单;首个入库计划生成后,整柜预计到仓时间不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
|
||||
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
@@ -1342,7 +1342,7 @@ onMounted(() => { void load() })
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
|
||||
<table class="density-table density-table--editable last-mile-command-table"><tbody>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
@@ -1360,8 +1360,7 @@ onMounted(() => { void load() })
|
||||
</tbody></table></el-form></section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
|
||||
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in manualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
|
||||
<section class="density-dialog-section" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in visibleManualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标重做登记"><h3 class="density-dialog-section__title">退回重做</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="reworkSaving"><table class="density-table density-table--editable"><tbody>
|
||||
@@ -1382,7 +1381,9 @@ onMounted(() => { void load() })
|
||||
.inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.inbound-plan-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.inbound-plan-form :deep(.el-select) { width: 100%; }
|
||||
.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); }
|
||||
.inbound-plan-pallet-field { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2) var(--admin-space-3); min-width: 0; }
|
||||
.inbound-plan-pallet-field :deep(.el-input-number) { width: 140px; flex: 0 0 140px; }
|
||||
.inbound-plan-pallet-note { margin: 0; line-height: 1.5; }
|
||||
.manual-nodes-editor th { width: 180px; }
|
||||
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
|
||||
.last-mile-toolbar { min-width: 0; }
|
||||
|
||||
@@ -220,7 +220,7 @@ onMounted(initialize)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
|
||||
.admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }
|
||||
.pallet-editor-table { table-layout: fixed; }
|
||||
.pallet-editor-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user