feat(delivery): let customers read and download signed receipts

This commit is contained in:
2026-09-30 12:09:22 +08:00
parent fa9bfd5f97
commit 6abb8b7388
4 changed files with 127 additions and 12 deletions
@@ -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<DeliveryReceipts>({
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<T> {
records: T[]
@@ -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, /<article class="delivery-detail-surface">/)
assert.match(detailSource, /<el-button link type="primary" @click="backToList"><ArrowLeft[^>]*\/>返回列表<\/el-button>/)
@@ -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<DeliveryOrderDetail | null>(null)
const statusLabels: Record<string, string> = { PENDING: '待派送', IN_TRANSIT: '派送中', DELIVERED: '已送达', SIGNED: '已签收', CANCELLED: '已取消' }
const receiptFiles = ref<DeliveryReceiptFile[]>([])
const receiptsLoading = ref(false)
const receiptsError = ref('')
const downloadingFileId = ref('')
const statusLabels: Record<string, string> = { 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<string, string> = { FBA_PICKUP: 'FBA 自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送' }
const pickupMethodLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提' }
const loadingTypeLabels: Record<string, string> = { 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<string, string>) {
@@ -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)
<DataTable :data="items" :columns="itemColumns" empty-text="暂无货物明细" />
</section>
<section class="delivery-detail-section" aria-label="签收资料">
<div class="delivery-detail-section-heading"><h3>签收资料</h3><span>共 {{ receiptFiles.length }} 个文件</span></div>
<div v-if="receiptsError" class="delivery-state delivery-state--error" role="alert">
<p>{{ receiptsError }}</p><el-button @click="loadReceipts">重试</el-button>
</div>
<DataTable v-else :data="receiptFiles" :columns="receiptColumns" :loading="receiptsLoading" empty-text="暂无签收资料">
<template #receiptActions="{ row }">
<el-button link type="primary" :loading="downloadingFileId === row.fileId" :disabled="!!downloadingFileId && downloadingFileId !== row.fileId" @click="downloadReceipt(row.fileId)">下载</el-button>
</template>
</DataTable>
</section>
<section class="delivery-detail-section">
<div class="delivery-detail-section-heading"><h3>费用明细</h3><span>共 {{ detail.fees.length }} 条</span></div>
<DataTable :data="detail.fees" :columns="feeColumns" empty-text="暂无费用明细" />
@@ -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'
}