feat: connect plan-level preload confirmation

This commit is contained in:
2026-08-15 17:04:00 +08:00
parent fc80d09faa
commit 835d1d0216
26 changed files with 398 additions and 267 deletions
@@ -40,7 +40,7 @@ import {
firstLegTransportModeLabel,
} from '@/shared/utils/firstLegBusinessLabels'
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
import SmartLoadingDialog from '@/modules/container-loading/components/SmartLoadingDialog.vue'
import { SmartLoadingDialog } from '@/modules/container-loading'
import {
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
@@ -5,7 +5,7 @@
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
- `POST /split/smart`:智能分柜/保存预装清单
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
- `GET /preload`:单票预装状态与明细只读查询;整份计划的确认统一在预装清单真实 `loadingPlan` 详情完成
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
- `POST /platform/v1/files/loading-evidence/{tenantId}/{inboundId}`:上传真实装柜证据文件
- `GET/POST /loading/evidence`:查询并登记五类装柜证据及锁码
@@ -4,7 +4,6 @@ import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
downloadEvidenceFile,
getContainerBooking,
@@ -62,15 +61,6 @@ describe('unified order workflow API contract', () => {
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
})
it('supports the intermediate confirming preload action', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
await confirmingPreload('2', '8', { expectedVersion: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
expect.objectContaining({ params: { tenantId: '2' } }),
)
})
it('uploads a real evidence file and voids evidence through audited endpoints', async () => {
vi.mocked(http.post)
.mockResolvedValueOnce({ data: { fileId: '88', fileName: 'empty.jpg' } })
@@ -186,10 +186,6 @@ export interface SmartSplitRequest {
remark?: string
}
export interface ConfirmPreloadRequest {
expectedVersion: number
}
export interface UpsertEvidenceRequest {
expectedVersion: number
evidenceType: EvidenceType
@@ -446,30 +442,6 @@ export async function smartSplit(
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmingPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
writeOptions('preload-confirming', inboundId, tenantId),
)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
writeOptions('preload-confirm', inboundId, tenantId),
)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function getPicking(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
@@ -83,6 +83,20 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
})
it('keeps single-order preload confirmation read-only and links to the plan list', async () => {
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'platform:firstleg:preload:list'])
await flushPromises()
expect(wrapper.text()).toContain('计划级预装确认')
expect(wrapper.text()).toContain('从实际预装计划详情确认整份计划')
expect(wrapper.text()).toContain('前往预装清单')
expect(wrapper.text()).not.toContain('提交确认')
expect(wrapper.text()).not.toContain('确认预装清单')
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'), 'utf8')
expect(source).toContain("router.push({ name: 'loading-plans' })")
})
it('does not query or expose editing controls without permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -15,8 +16,6 @@ import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '
import {
completeLoading,
completePicking,
confirmingPreload,
confirmPreload,
downloadEvidenceFile,
getEvidence,
getOutboundDispatch,
@@ -52,6 +51,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const router = useRouter()
const preload = ref<PreloadPlan | null>(null)
const preloadItems = ref<PreloadItem[]>([])
const picking = ref<PickingTask | null>(null)
@@ -87,8 +87,8 @@ const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
const canPreload = computed(() => !props.readOnly && access.can('firstleg:inbound:preload'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canViewLoadingPlans = computed(() => access.can('platform:firstleg:preload:list'))
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
@@ -292,18 +292,8 @@ async function runSmartSplit() {
}
}
async function startPreloadConfirmation() {
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已进入确认中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
async function finishPreloadConfirmation() {
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已确认,订单进入装柜中')
if (result?.preloadPlan) preload.value = result.preloadPlan
function openPreloadPlans() {
void router.push({ name: 'loading-plans' })
}
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
@@ -465,8 +455,11 @@ onMounted(() => { void refresh() })
</template>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
<div class="preload-confirmation-guide">
<div><strong>计划级预装确认</strong><span>当前单票状态:{{ preloadStatusLabel(preload?.status) }}。请前往预装清单,从实际预装计划详情确认整份计划。</span></div>
<el-button v-if="canViewLoadingPlans" type="primary" plain @click="openPreloadPlans"><Eye :size="15" />前往预装清单</el-button>
<span v-else class="workflow-hint">当前账号无预装清单列表权限。</span>
</div>
</el-card>
@@ -573,6 +566,11 @@ onMounted(() => { void refresh() })
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.preload-confirmation-guide { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.preload-confirmation-guide > div { display: grid; min-width: 0; gap: 4px; }
.preload-confirmation-guide strong { color: var(--cc-text, #1f2937); font-size: 13px; }
.preload-confirmation-guide span { color: var(--cc-text-secondary, #64748b); font-size: 12px; line-height: 1.5; }
.preload-confirmation-guide > .el-button { flex: 0 0 auto; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
@@ -592,6 +590,7 @@ onMounted(() => { void refresh() })
@media (max-width: 640px) {
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid { gap: 0; }
.preload-confirmation-guide { align-items: stretch; flex-direction: column; }
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}