feat(delivery): let customers read and download signed receipts
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user