This commit is contained in:
2026-09-23 17:46:08 +08:00
7 changed files with 65 additions and 7 deletions
@@ -374,6 +374,15 @@ export async function reviewReturnOrder(returnNo: string, customerId: string, de
return unwrapApiResponse(response)
}
export async function reviewDirectRelabelOrder(orderId: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
const response = await overseasHttp.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/relabel-review`,
{ status: decision === 'APPROVE' ? 'PENDING_PROCESSING' : 'REJECTED', remark: remark?.trim() || undefined },
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>) {
const { page: _page, size: _size, ...filters } = query
const params = Object.fromEntries(
@@ -30,7 +30,9 @@ export interface CreateRelabelItemPayload {
export interface CreateRelabelPayload {
customerOrderNo: string
overseasWarehouseId: string
operationWarehouseId?: string
/** @deprecated compatibility alias for older platform fixtures */
overseasWarehouseId?: string
labelDeadline: string
contactName: string
contactPhone: string
@@ -78,6 +80,8 @@ export interface RelabelSummaryDto {
fulfillmentStatus?: string | null
outboundOrderNo?: string | null
customerConfirmedAt?: string | null
reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER' | null
reviewRemark?: string | null
createdAt?: string | null
}
@@ -15,7 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
@@ -806,6 +806,25 @@ async function reviewReturn(source: object) {
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message)
}
}
async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
ElMessage.success('贴标单已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '贴标单审核失败').message) }
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '贴标单审核失败').message)
}
}
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') }
function hasRelabelActions(row: LastMileOrderListItem) {
@@ -830,6 +849,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
const inboundPlanCreated = hasInboundPlan(row)
return [
{ key: 'detail', label: '查看', priority: 500 },
{ key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated },
@@ -841,6 +861,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'review-direct-relabel') void reviewDirectRelabel(row)
else if (command === 'review-return') void reviewReturn(row)
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
@@ -19,4 +19,13 @@ describe('RelabelOrderDetailView', () => {
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
})
it('lets platform operators confirm completion or return PDA work for rework', () => {
expect(source).toContain('@click="confirmCompletion"')
expect(source).toContain('确认换标完成')
expect(source).toContain('@click="returnForRework"')
expect(source).toContain('退回重新贴标')
expect(source).toContain('await confirmRelabelCompletion(orderId.value)')
expect(source).toContain('await returnRelabelForRework(orderId.value')
})
})
@@ -1,11 +1,11 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { Eye, FileUp, Undo2 } from '@lucide/vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -22,6 +22,17 @@ const detail = ref<RelabelDetailDto | null>(null)
const loading = ref(false)
const access = useAccessPreviewStore()
const returning = ref(false)
const confirming = ref(false)
async function confirmCompletion() {
if (confirming.value) return
confirming.value = true
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(orderId.value)
ElMessage.success('已确认换标完成,订单已进入后续流程'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败,请刷新后重试').message) }
finally { confirming.value = false }
}
async function returnForRework() {
if (returning.value) return
returning.value = true
@@ -115,7 +126,7 @@ async function previewCompletionPhoto(fileId: string) {
<OverseasReadOnlyDetail class="admin-density" dense title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel"><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
@@ -144,6 +155,7 @@ async function previewCompletionPhoto(fileId: string) {
table { min-width: 1120px; }
.mark-code { color: #245b9e; font-weight: 600; }
.relabel-document-panel { grid-column: 1 / -1; padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.relabel-review-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-5); border:1px solid var(--cc-border); border-radius:var(--admin-radius); }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:var(--admin-space-2); color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
@@ -124,7 +124,7 @@ async function submitForm() {
return
}
submitting.value = true
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), operationWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
ElMessage.success('换标单已创建')
} else {
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
@@ -164,7 +164,8 @@ function relabelStatusCode(value: unknown) {
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
function canAudit(source: object) {
const row = source as RelabelOrderListItem
return (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
return row.reviewChannel !== 'TAIL_ORDER'
&& (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -174,6 +175,7 @@ async function load(model: QueryModel = filters.value) {
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
reviewChannel: item.reviewChannel || undefined,
createdAt: formatOverseasDateTime(item.createdAt),
}))
total.value = result.total
+1
View File
@@ -152,6 +152,7 @@ export interface RelabelOrderListItem {
fileName: string
status: string
rawStatus?: string
reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'
remark: string
createdAt: string
}