fix(overseas): expose permission-gated cargo discard review
This commit is contained in:
@@ -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<string, unknown> = {}) {
|
||||
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<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
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<ReturnType<typeof ElMessageBox.prompt>>)
|
||||
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<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user