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> {
|
export interface PageResult<T> {
|
||||||
records: 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/)
|
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', () => {
|
test('renders a compact customer-readable delivery detail surface', () => {
|
||||||
assert.match(detailSource, /<article class="delivery-detail-surface">/)
|
assert.match(detailSource, /<article class="delivery-detail-surface">/)
|
||||||
assert.match(detailSource, /<el-button link type="primary" @click="backToList"><ArrowLeft[^>]*\/>返回列表<\/el-button>/)
|
assert.match(detailSource, /<el-button link type="primary" @click="backToList"><ArrowLeft[^>]*\/>返回列表<\/el-button>/)
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ArrowLeft } from '@lucide/vue'
|
import { ArrowLeft } from '@lucide/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
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'
|
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||||
|
|
||||||
const props = defineProps<{ deliveryNo: string }>()
|
const props = defineProps<{ deliveryNo: string }>()
|
||||||
@@ -11,7 +12,16 @@ const router = useRouter()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const detail = ref<DeliveryOrderDetail | null>(null)
|
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 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 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: '混装' }
|
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 statusLabel(value: string) { return statusLabels[value] ?? display(value) }
|
||||||
function statusTone(value: string) {
|
function statusTone(value: string) {
|
||||||
if (value === 'DELIVERED' || value === 'SIGNED') return 'success'
|
if (value === 'DELIVERED') return 'success'
|
||||||
if (value === 'IN_TRANSIT') return 'primary'
|
if (value === 'DELIVERING') return 'primary'
|
||||||
if (value === 'CANCELLED') return 'danger'
|
if (value === 'CANCELLED' || value === 'REJECTED') return 'danger'
|
||||||
return 'warning'
|
return 'warning'
|
||||||
}
|
}
|
||||||
function mappedLabel(value: string | null | undefined, labels: Record<string, string>) {
|
function mappedLabel(value: string | null | undefined, labels: Record<string, string>) {
|
||||||
@@ -64,6 +74,7 @@ async function loadDetail() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
detail.value = await getDeliveryOrder(props.deliveryNo)
|
detail.value = await getDeliveryOrder(props.deliveryNo)
|
||||||
|
void loadReceipts()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
errorMessage.value = error instanceof Error ? error.message : '派送单详情加载失败,请重试'
|
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() {
|
function backToList() {
|
||||||
void router.push({ name: 'orders-delivery' })
|
void router.push({ name: 'orders-delivery' })
|
||||||
}
|
}
|
||||||
@@ -135,6 +182,18 @@ onMounted(loadDetail)
|
|||||||
<DataTable :data="items" :columns="itemColumns" empty-text="暂无货物明细" />
|
<DataTable :data="items" :columns="itemColumns" empty-text="暂无货物明细" />
|
||||||
</section>
|
</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">
|
<section class="delivery-detail-section">
|
||||||
<div class="delivery-detail-section-heading"><h3>费用明细</h3><span>共 {{ detail.fees.length }} 条</span></div>
|
<div class="delivery-detail-section-heading"><h3>费用明细</h3><span>共 {{ detail.fees.length }} 条</span></div>
|
||||||
<DataTable :data="detail.fees" :columns="feeColumns" empty-text="暂无费用明细" />
|
<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 canCreate = computed(() => auth.hasPermission('merchant:warehouse:delivery:create'))
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: 'PENDING', label: '待派送' },
|
{ value: 'WAIT_DELIVERY', label: '待派送' },
|
||||||
{ value: 'IN_TRANSIT', label: '派送中' },
|
{ value: 'DELIVERING', label: '派送中' },
|
||||||
{ value: 'DELIVERED', label: '已送达' },
|
{ value: 'DELIVERED', label: '已签收' },
|
||||||
{ value: 'SIGNED', label: '已签收' },
|
{ value: 'REJECTED', label: '已拒收' },
|
||||||
|
{ value: 'RETURN_PENDING', label: '待退回' },
|
||||||
|
{ value: 'RETURNED', label: '已退回' },
|
||||||
{ value: 'CANCELLED', label: '已取消' },
|
{ value: 'CANCELLED', label: '已取消' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -60,9 +62,10 @@ function statusLabel(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusType(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 === 'CANCELLED') return 'info'
|
||||||
if (status === 'IN_TRANSIT') return 'warning'
|
if (status === 'DELIVERING' || status === 'RETURN_PENDING') return 'warning'
|
||||||
|
if (status === 'REJECTED') return 'danger'
|
||||||
return 'primary'
|
return 'primary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user