From 6abb8b7388b18f8b6fd8bc091d535f50beee8ac8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:09:22 +0800 Subject: [PATCH] feat(delivery): let customers read and download signed receipts --- .../order-management/api/deliveryOrders.ts | 29 +++++++- .../order-management/deliveryOrders.test.mjs | 26 +++++++ .../views/DeliveryOrderDetailView.vue | 69 +++++++++++++++++-- .../views/DeliveryOrdersView.vue | 15 ++-- 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/src/modules/order-management/api/deliveryOrders.ts b/src/modules/order-management/api/deliveryOrders.ts index 6552c71..1598108 100644 --- a/src/modules/order-management/api/deliveryOrders.ts +++ b/src/modules/order-management/api/deliveryOrders.ts @@ -1,4 +1,31 @@ -import { apiRequest } from '@/api/http' +import { apiBlobRequest, apiRequest } from '@/api/http' + +export interface DeliveryReceiptFile { + fileId: string + fileName: string + contentType: string | null + sizeBytes: number + uploadedAt: string +} + +export interface DeliveryReceipts { + deliveryNo: string + files: DeliveryReceiptFile[] +} + +export function getDeliveryReceipts(deliveryNo: string) { + return apiRequest({ + method: 'GET', + url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/receipt`, + }) +} + +export function downloadDeliveryReceipt(deliveryNo: string, fileId: string) { + return apiBlobRequest({ + method: 'GET', + url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/receipt/files/${encodeURIComponent(fileId)}/download`, + }) +} export interface PageResult { records: T[] diff --git a/src/modules/order-management/deliveryOrders.test.mjs b/src/modules/order-management/deliveryOrders.test.mjs index 05e128b..02dcbe3 100644 --- a/src/modules/order-management/deliveryOrders.test.mjs +++ b/src/modules/order-management/deliveryOrders.test.mjs @@ -72,6 +72,32 @@ test('loads a typed detail route by delivery number and never exposes an interna assert.doesNotMatch(detailSource, /detail\?\.id|deliveryNo \|\| detail\?\.id|customerId|tenantId/) }) +test('receipt list and binary download use the fixed merchant scope without category or revision escape', async () => { + const api = await importTypeScriptModule(apiSource.replace( + "import { apiBlobRequest, apiRequest } from '@/api/http'", + `export const requests = [] + const apiRequest = async (config) => { requests.push(config); return { deliveryNo: 'DL/1', files: [] } } + const apiBlobRequest = async (config) => { requests.push(config); return new Blob(['receipt-bytes']) }`, + )) + assert.deepEqual(await api.getDeliveryReceipts('DL/1'), { deliveryNo: 'DL/1', files: [] }) + assert.equal(await (await api.downloadDeliveryReceipt('DL/1', '123')).text(), 'receipt-bytes') + assert.deepEqual(api.requests, [ + { method: 'GET', url: '/merchant/v1/last-leg/delivery-orders/DL%2F1/documents/receipt' }, + { method: 'GET', url: '/merchant/v1/last-leg/delivery-orders/DL%2F1/documents/receipt/files/123/download' }, + ]) + assert.match(detailSource, /empty-text="暂无签收资料"/) + assert.match(detailSource, /receiptsError[\s\S]*@click="loadReceipts">重试/) + assert.match(detailSource, /:loading="receiptsLoading"/) + assert.match(detailSource, /downloadDeliveryReceipt\(props\.deliveryNo, file\.fileId\)/) + assert.match(detailSource, /URL\.revokeObjectURL\(url\)/) + assert.match(detailSource, /DELIVERING: '派送中'/) + for (const status of ['WAIT_DELIVERY', 'DELIVERING', 'DELIVERED', 'REJECTED', 'RETURN_PENDING', 'RETURNED']) { + assert.match(listSource, new RegExp(`value: '${status}'`)) + } + assert.doesNotMatch(listSource, /value: '(?:PENDING|IN_TRANSIT|SIGNED)'/) + assert.doesNotMatch(detailSource, /el-upload|revisionVersion|documents\/BOL/) +}) + test('renders a compact customer-readable delivery detail surface', () => { assert.match(detailSource, /
/) assert.match(detailSource, /]*\/>返回列表<\/el-button>/) diff --git a/src/modules/order-management/views/DeliveryOrderDetailView.vue b/src/modules/order-management/views/DeliveryOrderDetailView.vue index 44c3dcb..a35e4d8 100644 --- a/src/modules/order-management/views/DeliveryOrderDetailView.vue +++ b/src/modules/order-management/views/DeliveryOrderDetailView.vue @@ -2,8 +2,9 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { ArrowLeft } from '@lucide/vue' +import { ElMessage } from 'element-plus' import dayjs from 'dayjs' -import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryOrderItem } from '../api/deliveryOrders' +import { getDeliveryOrder, getDeliveryReceipts, downloadDeliveryReceipt, type DeliveryReceiptFile, type DeliveryOrderDetail, type DeliveryOrderItem } from '../api/deliveryOrders' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' const props = defineProps<{ deliveryNo: string }>() @@ -11,7 +12,16 @@ const router = useRouter() const loading = ref(false) const errorMessage = ref('') const detail = ref(null) -const statusLabels: Record = { PENDING: '待派送', IN_TRANSIT: '派送中', DELIVERED: '已送达', SIGNED: '已签收', CANCELLED: '已取消' } +const receiptFiles = ref([]) +const receiptsLoading = ref(false) +const receiptsError = ref('') +const downloadingFileId = ref('') +const statusLabels: Record = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '已签收', REJECTED: '已拒收', RETURN_PENDING: '待退回', RETURNED: '已退回', CANCELLED: '已取消' } +const receiptColumns: DataTableColumn[] = [ + { label: '操作', slotName: 'receiptActions', width: 100 }, + { prop: 'fileName', label: '文件名称', minWidth: 260 }, + { prop: 'uploadedAt', label: '上传时间', width: 180, formatter: (_row: object, _column: unknown, value: string) => formatDateTime(value) }, +] const deliveryMethodLabels: Record = { FBA_PICKUP: 'FBA 自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送' } const pickupMethodLabels: Record = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提' } const loadingTypeLabels: Record = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装' } @@ -50,9 +60,9 @@ function formatQuantity(value: number | null | undefined, unit: string) { function statusLabel(value: string) { return statusLabels[value] ?? display(value) } function statusTone(value: string) { - if (value === 'DELIVERED' || value === 'SIGNED') return 'success' - if (value === 'IN_TRANSIT') return 'primary' - if (value === 'CANCELLED') return 'danger' + if (value === 'DELIVERED') return 'success' + if (value === 'DELIVERING') return 'primary' + if (value === 'CANCELLED' || value === 'REJECTED') return 'danger' return 'warning' } function mappedLabel(value: string | null | undefined, labels: Record) { @@ -64,6 +74,7 @@ async function loadDetail() { errorMessage.value = '' try { detail.value = await getDeliveryOrder(props.deliveryNo) + void loadReceipts() } catch (error) { detail.value = null errorMessage.value = error instanceof Error ? error.message : '派送单详情加载失败,请重试' @@ -72,6 +83,42 @@ async function loadDetail() { } } +async function loadReceipts() { + receiptsLoading.value = true + receiptsError.value = '' + receiptFiles.value = [] + try { + receiptFiles.value = (await getDeliveryReceipts(props.deliveryNo)).files + } catch (error) { + receiptsError.value = error instanceof Error ? error.message : '签收资料加载失败,请重试' + } finally { + receiptsLoading.value = false + } +} + +async function downloadReceipt(fileId: unknown) { + const file = receiptFiles.value.find(item => item.fileId === fileId) + if (!file) return + if (downloadingFileId.value) return + downloadingFileId.value = file.fileId + try { + const blob = await downloadDeliveryReceipt(props.deliveryNo, file.fileId) + const url = URL.createObjectURL(blob) + try { + const anchor = document.createElement('a') + anchor.href = url + anchor.download = file.fileName + anchor.click() + } finally { + URL.revokeObjectURL(url) + } + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '签收文件下载失败,请重试') + } finally { + downloadingFileId.value = '' + } +} + function backToList() { void router.push({ name: 'orders-delivery' }) } @@ -135,6 +182,18 @@ onMounted(loadDetail) +
+

签收资料

共 {{ receiptFiles.length }} 个文件
+ + + + +
+

费用明细

共 {{ detail.fees.length }} 条
diff --git a/src/modules/order-management/views/DeliveryOrdersView.vue b/src/modules/order-management/views/DeliveryOrdersView.vue index 94fe2ab..68ea63f 100644 --- a/src/modules/order-management/views/DeliveryOrdersView.vue +++ b/src/modules/order-management/views/DeliveryOrdersView.vue @@ -21,10 +21,12 @@ const filters = reactive({ documentNo: '', dispatchNo: '', waybillNo: '', status const canCreate = computed(() => auth.hasPermission('merchant:warehouse:delivery:create')) const statusOptions = [ - { value: 'PENDING', label: '待派送' }, - { value: 'IN_TRANSIT', label: '派送中' }, - { value: 'DELIVERED', label: '已送达' }, - { value: 'SIGNED', label: '已签收' }, + { value: 'WAIT_DELIVERY', label: '待派送' }, + { value: 'DELIVERING', label: '派送中' }, + { value: 'DELIVERED', label: '已签收' }, + { value: 'REJECTED', label: '已拒收' }, + { value: 'RETURN_PENDING', label: '待退回' }, + { value: 'RETURNED', label: '已退回' }, { value: 'CANCELLED', label: '已取消' }, ] @@ -60,9 +62,10 @@ function statusLabel(status: string) { } function statusType(status: string) { - if (status === 'DELIVERED' || status === 'SIGNED') return 'success' + if (status === 'DELIVERED') return 'success' if (status === 'CANCELLED') return 'info' - if (status === 'IN_TRANSIT') return 'warning' + if (status === 'DELIVERING' || status === 'RETURN_PENDING') return 'warning' + if (status === 'REJECTED') return 'danger' return 'primary' }