修复财务页面竞态并整理采购与运输展示
This commit is contained in:
@@ -310,6 +310,16 @@ describe('dispatch workflow native layout', () => {
|
||||
})
|
||||
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
|
||||
const { dialog } = await render(action)
|
||||
if (action === 'CANCEL') {
|
||||
expect(dialog.props('confirmText')).toBe('确认取消排车')
|
||||
expect(dialog.props('confirmType')).toBe('danger')
|
||||
expect(dialog.props('cancelText')).toBe('暂不取消')
|
||||
expect(dialog.props('width')).toBe('560px')
|
||||
expect(field(dialog, '取消原因').props()).toMatchObject({ labelPosition: 'top', required: true })
|
||||
expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写取消原因')
|
||||
expect(dialog.find('[aria-label="操作原因与意见"]').exists()).toBe(false)
|
||||
expect(dialog.text()).toContain('取消本次排车,不会自动回库。')
|
||||
}
|
||||
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
||||
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
|
||||
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
||||
@@ -318,6 +328,20 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
|
||||
})
|
||||
it('preserves the cancellation reason after failure and refreshing current facts', async () => {
|
||||
const { dialog } = await render('CANCEL')
|
||||
await dialog.get('textarea').setValue(' 客户调整送货安排 ')
|
||||
vi.mocked(api.cancelDispatch).mockRejectedValueOnce(new ApiResponseError('排车资料已更新,请核对后重试', 'A0409', 409))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeUndefined()
|
||||
expect(dialog.props('confirmLoading')).toBe(false)
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.cancelDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.cancelDispatch).mock.calls[1]![1]).toMatchObject({ recordVersion: 1, reason: '客户调整送货安排' })
|
||||
})
|
||||
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
|
||||
const { dialog } = await render('EDIT', 'ROUTE')
|
||||
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
|
||||
|
||||
@@ -150,7 +150,7 @@ function payload(): object {
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
|
||||
case 'CANCEL': case 'RETURN_TO_REVIEW':
|
||||
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
|
||||
if (!form.reason.trim()) throw new DispatchInputError(currentAction.value === 'CANCEL' ? '请填写取消原因' : '请填写操作原因')
|
||||
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
|
||||
case 'LOADING_COMPLETE': {
|
||||
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
|
||||
@@ -250,7 +250,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'CANCEL' ? '确认取消排车' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" :confirm-type="currentAction === 'CANCEL' ? 'danger' : 'primary'" :cancel-text="currentAction === 'CANCEL' ? '暂不取消' : '取消'" @confirm="submit()">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
@@ -313,14 +313,17 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section>
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="取消本次排车,不会自动回库。" type="warning" :closable="false" show-icon />
|
||||
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<el-form-item v-if="currentAction === 'CANCEL' && currentOperation !== 'ROUTE'" label="取消原因" label-position="top" required>
|
||||
<el-input v-model="form.reason" aria-label="取消原因" type="textarea" :rows="4" placeholder="请填写取消原因" />
|
||||
</el-form-item>
|
||||
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,23 @@ describe('OutboundDetailView', () => {
|
||||
emptyWrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['WAIT_PICK', '待拣货'], ['WAIT_OUTBOUND', '待出库'], ['PICKING', '拣货中'],
|
||||
['PARTIAL_PICKED', '部分已拣货'], ['PICKED', '已拣货'], ['OUTBOUNDED', '已出库'], ['CANCELLED', '已取消'],
|
||||
[null, '—'], ['FUTURE_STATUS', '未知状态'],
|
||||
])('renders outbound pallet status %s in Chinese', async (status, label) => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
|
||||
pallets: [{ palletNo: 'DP-1', status }], deliveries: [],
|
||||
})
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const table = wrapper.get('.outbound-pallet-table')
|
||||
expect(table.text()).toContain(label)
|
||||
if (status) expect(table.text()).not.toContain(status)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows cancellation only before picking with the independent permission', async () => {
|
||||
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({ outbound: { id: '1', outboundNo: 'OUT/1', status: 'WAIT_PICK' }, pallets: [], deliveries: [] })
|
||||
|
||||
@@ -24,10 +24,10 @@ const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
|
||||
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseName = ref('')
|
||||
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
|
||||
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PARTIAL_PICKED: '部分已拣货', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', label: '托盘码', width: 220 },
|
||||
{ key: 'status', label: '状态', width: 110, afterColumn: 'palletNo' },
|
||||
{ key: 'status', label: '状态', width: 110, afterColumn: 'palletNo', formatter: (_row: unknown, _column: unknown, value: unknown) => palletStatusLabel(value) },
|
||||
{ key: 'inboundCode', label: '入仓码', width: 190 },
|
||||
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
|
||||
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
|
||||
@@ -62,6 +62,7 @@ watch(() => detail.value, async (value, _previous, onCleanup) => {
|
||||
}
|
||||
})
|
||||
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
|
||||
function palletStatusLabel(value: unknown) { const code = String(value ?? '').trim(); return code ? statusLabels[code] || '未知状态' : '—' }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
|
||||
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
|
||||
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
|
||||
|
||||
Reference in New Issue
Block a user