diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 3b75310e..c13e50c0 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -364,6 +364,15 @@ export async function getLastMileOrder(orderId: string) { return unwrapApiResponse(response) } +export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { + const response = await overseasHttp.post, ApiEnvelope>( + `/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`, + { decision, remark: remark?.trim() || undefined }, + { headers: { 'X-Customer-Id': customerId } }, + ) + return unwrapApiResponse(response) +} + export async function downloadLastMileOrders(query: Partial) { const { page: _page, size: _size, ...filters } = query const params = Object.fromEntries( diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index a55bd281..1a5b747b 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -98,6 +98,7 @@ export interface RelabelItemDto { cartonMark?: string | null fbaCartonMark?: string | null destinationWarehouseId?: string | null + destinationFbaWarehouseCode?: string | null } export interface RelabelHistoryDto { diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 3911ec2f..5efd50d8 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -15,10 +15,12 @@ const orderTypeLabels: Record = { const statusLabels: Record = { PENDING_REVIEW: '待审核', + PENDING_RECEIPT: '待签收入库', + PARTIALLY_RECEIVED: '部分入库', + INBOUND_COMPLETED: '全部入库', WAIT_PDA_RECEIVING: '入库测量中', INBOUND_MEASURING: '入库测量中', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', - INBOUND_COMPLETED: '入库完成', APPROVED: '审核通过', PENDING_ASSIGNMENT: '待分配', WAIT_RELABEL: '待换标', @@ -27,7 +29,6 @@ const statusLabels: Record = { WAIT_OUTBOUND: '待出库', ALLOCATED: '已分配库存', OUTBOUNDED: '已出库', - PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', IN_TRANSIT: '运输中', DESTINATION_ASSIGNED: '已分配目的仓', @@ -35,6 +36,7 @@ const statusLabels: Record = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', + REJECTED: '已驳回', CANCELLED: '已取消', } @@ -145,6 +147,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO customerId: String(dto.customerId), sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(), orderNo: lastMileOrderReference(dto.orderNo), + sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''), orderTypeCode: String(dto.orderType ?? '').trim(), currentStatusCode: String(dto.currentStatus ?? '').trim(), warning: dto.abnormal ? '异常' : '—', diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index c07872ad..62e7e87c 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -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, 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, 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' @@ -440,6 +440,11 @@ async function submitReceivingWarehouseChange() { } } function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] } +function isRejectedReturn(source: object) { + const row = source as LastMileOrderListItem + return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED' +} +function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) } async function loadTransferWarehouseOptions() { transferWarehouseLoading.value = true transferWarehouseError.value = null @@ -461,6 +466,7 @@ function selectTransferWarehouse(warehouseId: string) { } function openTransfer() { if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return } + if (hasSelectedRejectedReturn()) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' }) transferWarehouseOptions.value = [] transferWarehouseError.value = null @@ -493,6 +499,8 @@ function idempotencyKey(prefix: string) { function openInboundPlan(source?: object) { const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null) if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } + if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } + if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return } actionTarget.value = target const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1 inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum, @@ -776,11 +784,44 @@ watch(containerConvertOpen, (open) => { containerConversionIdempotencyKey = '' }) +function rowActionCount(source: object) { + const row = source as LastMileOrderListItem + if (isRejectedReturn(row)) return 1 + const count = 1 + + Number(access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW') + + Number(access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW') + + Number(access.can(permission.inboundOrderCreate)) + + (access.can(permission.lastMileOrderTransfer) ? 2 : 0) + + Number(access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row)) + + Number(access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row)) + return hasRelabelActions(row) ? Math.max(4, count) : count +} +async function reviewReturn(source: object) { + const row = source as LastMileOrderListItem + const returnNo = row.sourceOrderNo + if (!returnNo || !row.customerId) return + try { + await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true }) + await reviewReturnOrder(returnNo, 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 reviewReturnOrder(returnNo, 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 lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { return [ { key: 'detail', label: '查看', priority: 500 }, + { 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) }, { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' }, @@ -791,6 +832,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { } function handleLastMileRowAction(command: string, row: LastMileOrderListItem) { if (command === 'detail') openDetail(row) + else if (command === 'review-return') void reviewReturn(row) else if (command === 'review') openTransferReview(row) else if (command === 'inbound-plan') openInboundPlan(row) else if (command === 'hold') openHold(row) @@ -890,7 +932,7 @@ onMounted(() => { void load() })
- + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts index f6c0b348..595a3caa 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts @@ -13,4 +13,10 @@ describe('RelabelOrderDetailView', () => { expect(source).toContain('text(item.fbaCartonMark)') expect(source).not.toContain("{ title: '箱唛换标信息', fields:") }) + + it('shows the review state and customer-facing return order and FBA warehouse codes', () => { + expect(source).toContain("PENDING_REVIEW: '待审核'") + expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo') + expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId') + }) }) diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue index f6ca525c..7ff83847 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue @@ -46,9 +46,16 @@ const groups = [ ] function text(value: unknown) { return String(value ?? '').trim() || '—' } function status(value: unknown) { - if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认' - if (value === 'PROCESSING') return '处理中' - return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(String(value || '')) ? '换标完成' : '待处理' + return ({ + PENDING_REVIEW: '待审核', + PENDING_PROCESSING: '待处理', + PROCESSING: '处理中', + CUSTOMER_CONFIRM_PENDING: '待客户确认', + READY_FOR_OUTBOUND: '换标完成', + COMPLETED: '换标完成', + REJECTED: '已驳回', + CANCELLED: '已取消', + } as Record)[String(value || '')] || text(value) } function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record)[String(value || '')] || text(value) } const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充')) @@ -57,7 +64,7 @@ const fieldValues = computed>(() => { const items = value?.items || [] return { 换标单号: text(value?.application.relabelNo), - 关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo), + 关联订单: text(value?.application.customerOrderNo || value?.application.lastlegOrderNo), 状态: status(value?.application.status), 资金状态: walletStatus(value?.application.walletStatus), 创建时间: formatOverseasDateTime(value?.application.createdAt), @@ -117,7 +124,7 @@ async function previewCompletionPhoto(fileId: string) { - +
我方箱唛FBA 箱唛商品 SKU中文品名FBA 货件编号换标新 SKU目的仓明细PDF
{{ text(item.cartonMark) }}{{ text(item.fbaCartonMark) }}{{ text(item.skuCode) }}{{ text(item.productNameZh) }}{{ text(item.shipmentNo) }}{{ text(item.newSkuCode) }}{{ text(item.destinationWarehouseId) }}{{ item.relabelFileName }}预览
{{ text(item.cartonMark) }}{{ text(item.fbaCartonMark) }}{{ text(item.skuCode) }}{{ text(item.productNameZh) }}{{ text(item.shipmentNo) }}{{ text(item.newSkuCode) }}{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}{{ item.relabelFileName }}预览
暂无箱唛换标信息
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts index 4b5cfcd3..4bdb1c06 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts @@ -109,19 +109,27 @@ describe('RelabelOrdersView', () => { })) }) - it('offers only backend-allowed audit transitions', async () => { + it('hides the audit action after approval', async () => { vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1, }) const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text().includes('审核'))!.trigger('click') - await flushPromises() + expect(wrapper.findAll('button').some((button) => button.text().trim() === '审核')).toBe(false) + }) - const labels = formItem(wrapper, '目标状态').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label')) - expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消'])) - expect(labels).not.toContain('已完成') + it('shows the audit action only while pending review', async () => { + vi.mocked(listRelabelOrders).mockResolvedValueOnce({ + records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_REVIEW' }], + page: 1, size: 10, total: 1, pages: 1, + }) + const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) + await flushPromises() + await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click') + await flushPromises() + const labels = formItem(wrapper, '审核动作').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label')) + expect(labels).toEqual(['审核通过', '已驳回']) }) it('passes the relabel order number search to the backend', async () => { diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue index a2ac6a4a..1ac13a66 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue @@ -35,7 +35,7 @@ const filters = ref({ relabelNo: '', sku: '', status: '' }) const fields = computed(() => [ { key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 }, { key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 }, - { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] }, + { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] }, ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, @@ -58,6 +58,7 @@ const editSourceStatus = ref('') const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' }) const createIdempotencyKey = ref('') const allowedStatusOptions = computed(() => { + if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED'] if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED'] if (editSourceStatus.value === 'PROCESSING') return ['COMPLETED', 'REJECTED', 'CANCELLED'] return [] @@ -127,6 +128,7 @@ async function submitForm() { ElMessage.success('换标单已创建') } else { if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return } + if (form.status === 'REJECTED' && !form.remark.trim()) { ElMessage.warning('审核驳回请填写原因'); return } submitting.value = true await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined) ElMessage.success('状态已更新') @@ -148,6 +150,7 @@ function relabelStatus(value: unknown) { } function relabelAuditStatusLabel(value: string) { return ({ + PENDING_PROCESSING: '审核通过', PROCESSING: '处理中', REJECTED: '已驳回', CANCELLED: '已取消', @@ -155,13 +158,13 @@ function relabelAuditStatusLabel(value: string) { } as Record)[value] || relabelStatus(value) } function relabelStatusCode(value: unknown) { - const codes: Record = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' } + const codes: Record = { 待审核: 'PENDING_REVIEW', 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' } return codes[String(value || '')] || String(value || 'PENDING_PROCESSING') } function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record)[String(value || '')] || String(value || '—') } function canAudit(source: object) { const row = source as RelabelOrderListItem - return ['PENDING_PROCESSING', 'PROCESSING'].includes(row.rawStatus || relabelStatusCode(row.status)) + return (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW' } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) @@ -205,7 +208,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' }) - +

作业要求

diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index 14de1e43..4879b9e0 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -28,6 +28,7 @@ export const overseasWarehousePermissions = { inventoryEdit: 'platform:overseas-warehouse:inventory:edit', inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust', returnInventoryList: 'platform:overseas-warehouse:return-inventory:list', + returnOrderReview: 'platform:overseas-warehouse:return-inventory:list', returnCartonsList: 'platform:overseas-warehouse:return-cartons:list', relabelOrderList: 'platform:overseas-warehouse:relabel-order:list', relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create', diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index 6af1448f..c6a0cc22 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -8,6 +8,7 @@ export interface LastMileOrderListItem { customerId: string sourceContainerBookingId: string orderNo: string + sourceOrderNo: string orderTypeCode: string currentStatusCode: string warning: string