From cc973c72c08627879c9f13919cca30f4e92e137f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 16:57:16 +0800 Subject: [PATCH] fix(overseas): expose permission-gated cargo discard review --- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 2 + .../api/lastMileOrders.test.ts | 8 ++ .../overseas-warehouse/api/lastMileOrders.ts | 9 ++ .../pages/LastMileOrdersView.behavior.test.ts | 92 ++++++++++++++++++- .../pages/LastMileOrdersView.vue | 41 +++++++++ src/modules/overseas-warehouse/permissions.ts | 1 + 6 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index f72d0221..7dc81808 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -4,6 +4,8 @@ 当前范围按海外仓全流程维护。箱号管理、产品库不设独立正式路由,其数据通过入仓、箱唛、SKU 与库存页面承载;正式范围包含尾程订单、入库单、换标、转运、退货、托盘、库存、排车、出库、派送、异常退回、客户回访和一件代发。 +废弃审核补充:`page-last-mile-orders` 原型未定义废弃审核弹框。依当前对话“你确认就好了”的常规交互决策授权,沿用本页 `reviewReturn` / `reviewDirectRelabel` 的居中确认框,不新增路由或抽屉。只对有 `lastleg:cargo-discard:approve` 权限的待审核废弃行提供审核;框内显示废弃申请号,通过或驳回,驳回原因必填且最多 1000 字,关闭不提交,处理期间禁止重复点击。仅提交审核结论,不增加扣库命令或声明扣库时点。详情仍走本页原独立详情路由。本段定义承载方式,部署及验收状态只记录在测试环境主契约。 + | 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 | |---|---|---|---|---|---| | 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 | diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 87979865..546b6717 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,10 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' +import { reviewCargoDiscard } from './lastMileOrders' import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders' vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) describe('last-mile order overseas API', () => { + it('reviews a discard by business number with an explicit string customer query', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { status: 'REJECTED' } }) + await expect(reviewCargoDiscard('CD/TEST-1', '2092618717861048321', 'REJECT', ' 箱数不符 ')).resolves.toEqual({ status: 'REJECTED' }) + expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/cargo-discards/CD%2FTEST-1/review', + { decision: 'REJECT', remark: '箱数不符' }, { params: { customerId: '2092618717861048321' } }) + }) + beforeEach(() => { vi.mocked(http.get).mockReset() vi.mocked(http.put).mockReset() diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 0f69350d..dc32f320 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -365,6 +365,15 @@ export async function getLastMileOrder(orderId: string) { return unwrapApiResponse(response) } +export async function reviewCargoDiscard(discardNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { + const response = await http.post, ApiEnvelope>( + `/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`, + { decision, remark: remark?.trim() || undefined }, + { params: { customerId } }, + ) + return unwrapApiResponse(response) +} + export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`, diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index ffc35d35..11ced91b 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -1,4 +1,4 @@ -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessageBox, ElMessage } from 'element-plus' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { createPinia } from 'pinia' @@ -17,6 +17,7 @@ import { getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, + reviewCargoDiscard, transferLastMileOrders, getLastMileOrderManualNodes, updateLastMileOrderHold, @@ -34,6 +35,7 @@ vi.mock('../api/lastMileOrders', () => ({ createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(), getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(), downloadLastMileOrders: vi.fn(), + reviewCargoDiscard: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } @@ -257,6 +259,8 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) beforeEach(() => { + vi.restoreAllMocks() + vi.mocked(reviewCargoDiscard).mockReset().mockResolvedValue({}) canAccess.mockReset().mockReturnValue(true) vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) @@ -280,6 +284,92 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2) }) + async function openDiscard(overrides: Record = {}) { + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ + id: '2092873463568596994', customerId, orderNo: 'CD-TEST-1', sourceOrderNo: 'CD-TEST-1', + orderType: 'CARGO_DISCARD', currentStatus: 'PENDING_REVIEW', inboundOrderNo: 'CD-TEST-1', ...overrides, + }], page: 1, size: 10, total: 1, pages: 1 }) + return mountView() + } + + it.each([ + ['lastleg:cargo-discard:approve', 'PENDING_REVIEW', true], + ['platform:overseas-warehouse:last-mile-order:edit', 'PENDING_REVIEW', false], + ['lastleg:cargo-discard:approve', 'APPROVED', false], + ['lastleg:cargo-discard:approve', 'CANCELLED', false], + ])('gates discard review on exact permission %s and status %s', async (code, currentStatus, visible) => { + canAccess.mockImplementation(permissionCode => permissionCode === code) + const wrapper = await openDiscard({ currentStatus }) + expect(Boolean(button(wrapper, '审核'))).toBe(visible) + expect(reviewCargoDiscard).not.toHaveBeenCalled() + }) + + it.each([{ orderNo: '' }, { customerId: '' }])('does not expose discard review without a business target %j', async (overrides) => { + const wrapper = await openDiscard(overrides) + expect(button(wrapper, '审核')).toBeUndefined() + expect(reviewCargoDiscard).not.toHaveBeenCalled() + }) + + it('approves the selected discard once and reloads the server status', async () => { + const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited>) + const wrapper = await openDiscard() + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ + id: '2092873463568596994', customerId, orderNo: 'CD-TEST-1', + orderType: 'CARGO_DISCARD', currentStatus: 'APPROVED', inboundOrderNo: 'CD-TEST-1', + }], page: 1, size: 10, total: 1, pages: 1 }) + await button(wrapper, '审核')!.trigger('click') + await flushPromises() + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核', expect.objectContaining({ distinguishCancelAndClose: true })) + expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'APPROVE', undefined) + expect(button(wrapper, '审核')).toBeUndefined() + }) + + it('rejects with a trimmed mandatory reason without approving first', async () => { + vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel') + const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: ' 箱数不符 ', action: 'confirm' } as Awaited>) + const wrapper = await openDiscard() + await button(wrapper, '审核')!.trigger('click') + await flushPromises() + const options = prompt.mock.calls[0]?.[2] + expect(options?.inputValidator?.(' ')).not.toBe(true) + expect(options?.inputValidator?.('甲'.repeat(1001))).not.toBe(true) + expect(options?.inputValidator?.('箱数不符')).toBe(true) + expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'REJECT', '箱数不符') + }) + + it('closing review or cancelling the reason makes no request', async () => { + vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('close').mockRejectedValueOnce('cancel') + vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue('cancel') + const wrapper = await openDiscard() + await button(wrapper, '审核')!.trigger('click') + await flushPromises() + await button(wrapper, '审核')!.trigger('click') + await flushPromises() + expect(reviewCargoDiscard).not.toHaveBeenCalled() + expect(listLastMileOrders).toHaveBeenCalledTimes(1) + }) + + it('locks repeat clicks while review is pending and allows retry after an API failure', async () => { + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited>) + const errorToast = vi.spyOn(ElMessage, 'error') + let rejectRequest!: (error: Error) => void + vi.mocked(reviewCargoDiscard).mockImplementationOnce(() => new Promise((_, reject) => { rejectRequest = reject })) + const wrapper = await openDiscard() + await button(wrapper, '审核')!.trigger('click') + await flushPromises() + expect((button(wrapper, '审核')!.element as HTMLButtonElement).disabled).toBe(true) + await button(wrapper, '审核')!.trigger('click') + expect(reviewCargoDiscard).toHaveBeenCalledTimes(1) + rejectRequest(new Error('审批请求失败')) + await flushPromises() + expect(errorToast).toHaveBeenCalled() + expect((button(wrapper, '审核')!.element as HTMLButtonElement).disabled).toBe(false) + expect(listLastMileOrders).toHaveBeenCalledTimes(1) + await button(wrapper, '审核')!.trigger('click') + await flushPromises() + expect(reviewCargoDiscard).toHaveBeenCalledTimes(2) + }) + it.each([ ['PENDING_ASSIGNMENT', false], ['INBOUND_COMPLETED', true], diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 150c6d23..f950e4c0 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -17,6 +17,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' 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 { reviewCargoDiscard } from '../api/lastMileOrders' import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue' import { confirmRelabelCompletion, createReturnPackingPlan, getRelabelReviewTask, returnRelabelForRework, type ReturnPackingPlanBox } from '../api/relabelOrders' import { useAccessPreviewStore } from '@/core/access/accessPreview' @@ -843,6 +844,44 @@ watch(containerConvertOpen, (open) => { containerConversionIdempotencyKey = '' }) +const discardReviewBusy = ref(false) +function canReviewDiscard(row: LastMileOrderListItem) { + return access.can(permission.cargoDiscardApprove) + && row.orderTypeCode === 'CARGO_DISCARD' + && row.currentStatusCode === 'PENDING_REVIEW' + && Boolean(row.orderNo && row.orderNo !== '—') + && /^[1-9]\d*$/.test(row.customerId) +} +async function reviewDiscard(row: LastMileOrderListItem) { + if (discardReviewBusy.value || !canReviewDiscard(row)) return + discardReviewBusy.value = true + try { + let decision: 'APPROVE' | 'REJECT' = 'APPROVE' + let remark: string | undefined + try { + await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, '废弃申请审核', { + customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true, + }) + } catch (action) { + if (action !== 'cancel') throw action + const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃申请', { + customClass: 'admin-density-confirm', inputType: 'textarea', + inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因', + confirmButtonText: '确认驳回', cancelButtonText: '取消', + }) + decision = 'REJECT' + remark = value.trim() + } + await reviewCargoDiscard(row.orderNo, row.customerId, decision, remark) + ElMessage.success(decision === 'APPROVE' ? '废弃申请已审核通过' : '废弃申请已驳回') + await load() + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '废弃申请审核失败').message) + } finally { + discardReviewBusy.value = false + } +} + async function reviewReturn(source: object) { const row = source as LastMileOrderListItem const returnNo = row.sourceOrderNo @@ -906,6 +945,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { const inboundPlanCreated = hasInboundPlan(row) return [ { key: 'detail', label: '查看', priority: 500 }, + { key: 'review-discard', label: '审核', priority: 480, tone: 'warning', visible: canReviewDiscard(row), disabled: discardReviewBusy.value }, { 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' }, @@ -918,6 +958,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { } function handleLastMileRowAction(command: string, row: LastMileOrderListItem) { if (command === 'detail') openDetail(row) + else if (command === 'review-discard') void reviewDiscard(row) else if (command === 'review-direct-relabel') void reviewDirectRelabel(row) else if (command === 'review-return') void reviewReturn(row) else if (command === 'review') openTransferReview(row) diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index e73a8a7c..efc9d975 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -3,6 +3,7 @@ export const overseasWarehousePermissions = { lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export', lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit', warehouseTransferApprove: 'platform:overseas-warehouse:warehouse-transfer:approve', + cargoDiscardApprove: 'lastleg:cargo-discard:approve', inboundOrderList: 'platform:overseas-warehouse:inbound-order:list', inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create', inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',