Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1028b715ab | ||
|
|
cbd849444f | ||
|
|
e7dc68473e |
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
@@ -131,10 +132,9 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
|
|||||||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p>
|
|
||||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
|
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||||||
<template #toolbar-actions><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||||||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|||||||
const batch = 'FBA_APPOINTMENT:1'
|
const batch = 'FBA_APPOINTMENT:1'
|
||||||
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||||
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
||||||
function candidate(value = row()): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] } }
|
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||||
}
|
}
|
||||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -37,7 +38,7 @@ function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(st
|
|||||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||||
function reasons(row: BatchRow) { return row.blockReasons }
|
function reasons(row: BatchRow) { return row.blockReasons }
|
||||||
function physicalCount(values: BatchRow[]) { return new Set(values.map(row => row.native?.selection.warehousePalletId || row.native?.sourceKey || row.key)).size }
|
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!readable.value || pending.value || corrupted.value) return
|
if (!readable.value || pending.value || corrupted.value) return
|
||||||
@@ -77,7 +78,7 @@ async function load() {
|
|||||||
const sources = receiving[index]!.batch.items
|
const sources = receiving[index]!.batch.items
|
||||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||||
if (candidate) blocks.push(...candidate.blockReasons)
|
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||||||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||||
@@ -131,7 +132,11 @@ async function executeStep() {
|
|||||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||||
request.lockedKeys.push(source.sourceKey)
|
const group = requireSelectionGroup(source)
|
||||||
|
assertSelectionGroupVersions([expected!.native], [source])
|
||||||
|
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||||
|
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||||
|
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||||
}
|
}
|
||||||
request.draft = draft
|
request.draft = draft
|
||||||
}
|
}
|
||||||
@@ -163,7 +168,7 @@ async function save() {
|
|||||||
else {
|
else {
|
||||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: cargo.map(source => source!.selection), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +204,7 @@ onBeforeUnmount(() => { generation++ })
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
<section class="work-form" aria-label="按预约批次创建整车排车">
|
||||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }}</h3><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p>
|
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const access = vi.hoisted(() => ({ allowed: true }))
|
|||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
||||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
|
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||||
@@ -51,7 +52,7 @@ watch(() => props.dispatches, load, { immediate: true, deep: true })
|
|||||||
onBeforeUnmount(() => { generation++ })
|
onBeforeUnmount(() => { generation++ })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车</h3><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p>
|
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
|
||||||
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
|
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
@@ -386,7 +387,7 @@ onMounted(load)
|
|||||||
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
||||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案 <InlineHelp title="装柜说明"><p>智能建议必须经人工核对。</p><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></InlineHelp></h2></div></template>
|
||||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
||||||
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||||
@@ -396,7 +397,7 @@ onMounted(load)
|
|||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
||||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
|
<template #toolbar-start><div><h2>候选货物</h2></div></template>
|
||||||
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
|
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
|
||||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="overseas-fee-panel admin-density customer-density">
|
<section class="overseas-fee-panel admin-density customer-density">
|
||||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
<header class="panel-heading"><div><h2>海外仓费用</h2></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
|
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
|
||||||
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
||||||
<template v-else-if="config">
|
<template v-else-if="config">
|
||||||
@@ -88,7 +88,7 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||||
.edit-group-heading h3 { min-width:0; }
|
.edit-group-heading h3 { min-width:0; }
|
||||||
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||||
|
|||||||
@@ -1083,7 +1083,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
|
|
||||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
||||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid three-cols">
|
<FormSection title="基础资料"><div class="form-grid three-cols">
|
||||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||||
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
|
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
|
||||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||||
@@ -1093,7 +1093,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||||
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
|
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
|
||||||
|
|
||||||
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid three-cols">
|
<FormSection title="联系与商务"><div class="form-grid three-cols">
|
||||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
|
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
|
||||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('DashboardView', () => {
|
|||||||
const wrapper = mount(DashboardView)
|
const wrapper = mount(DashboardView)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||||
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
|
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
|
||||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
|
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
|
||||||
expect(wrapper.findAll('.dashboard-metric p').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
|
expect(wrapper.findAll('.dashboard-metric p').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
|
||||||
@@ -77,7 +77,7 @@ describe('DashboardView', () => {
|
|||||||
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click')
|
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(getTodayNewOrderCount).toHaveBeenCalledTimes(2)
|
expect(getTodayNewOrderCount).toHaveBeenCalledTimes(2)
|
||||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||||
expect(wrapper.text()).toContain('12 票')
|
expect(wrapper.text()).toContain('12 票')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
|||||||
<template>
|
<template>
|
||||||
<section ref="dashboardRef" class="dashboard-view">
|
<section ref="dashboardRef" class="dashboard-view">
|
||||||
<AdminPage as="div" spacing="normal">
|
<AdminPage as="div" spacing="normal">
|
||||||
<section class="dashboard-context" aria-label="数据看板说明">
|
<section class="dashboard-context" aria-label="数据看板操作">
|
||||||
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
<p v-if="!hasMetrics || metricsError">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
||||||
<div class="dashboard-head-actions">
|
<div class="dashboard-head-actions">
|
||||||
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||||
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||||
@@ -119,7 +119,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
|||||||
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
||||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
||||||
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
.dashboard-head-actions { margin-left: auto; display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||||
.dashboard-outline-button { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
.dashboard-outline-button { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||||
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
|
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
|
||||||
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
|
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ onMounted(() => {
|
|||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
>
|
>
|
||||||
<el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
<el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
||||||
<FormSection class="density-form-section" title="基本信息" description="费用项目为平台公共主数据,不按客户单独维护。">
|
<FormSection class="density-form-section" title="基本信息">
|
||||||
<div class="form-grid two-cols">
|
<div class="form-grid two-cols">
|
||||||
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
|
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
|
||||||
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
|
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ onMounted(loadInitialData)
|
|||||||
|
|
||||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||||
<el-form class="density-form" ref="formRef" :model="form" :rules="formRules" label-position="top">
|
<el-form class="density-form" ref="formRef" :model="form" :rules="formRules" label-position="top">
|
||||||
<FormSection class="density-form-section" title="用户资料" description="维护登录身份、联系方式与平台角色。">
|
<FormSection class="density-form-section" title="用户资料">
|
||||||
<div class="form-grid two-cols">
|
<div class="form-grid two-cols">
|
||||||
<el-form-item label="用户名" prop="username" required>
|
<el-form-item label="用户名" prop="username" required>
|
||||||
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
|
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ onMounted(loadRoles)
|
|||||||
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form density-form">
|
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form density-form">
|
||||||
<div class="role-editor-layout">
|
<div class="role-editor-layout">
|
||||||
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。" class="role-editor-basic density-form-section">
|
<FormSection title="基本信息" class="role-editor-basic density-form-section">
|
||||||
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item>
|
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ onBeforeUnmount(() => {
|
|||||||
<h2>入库码:{{ title }}</h2>
|
<h2>入库码:{{ title }}</h2>
|
||||||
<StatusBadge v-if="detail" :status="statusLabel" />
|
<StatusBadge v-if="detail" :status="statusLabel" />
|
||||||
</div>
|
</div>
|
||||||
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
|
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
|
||||||
<RefreshCw :size="16" />刷新
|
<RefreshCw :size="16" />刷新
|
||||||
@@ -235,7 +234,6 @@ onBeforeUnmount(() => {
|
|||||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
|
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
|
||||||
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
|
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
|
||||||
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
|
||||||
.workspace-state { margin: 24px; }
|
.workspace-state { margin: 24px; }
|
||||||
.workspace-skeleton { padding: 0; }
|
.workspace-skeleton { padding: 0; }
|
||||||
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
|
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Check, CircleDollarSign, PencilLine, RefreshCw, Send } from '@lucide/vue'
|
import { Check, CircleDollarSign, PencilLine, RefreshCw, Send } from '@lucide/vue'
|
||||||
@@ -379,12 +380,11 @@ defineExpose({ reload: loadFee })
|
|||||||
<template>
|
<template>
|
||||||
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
||||||
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
|
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
|
||||||
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算</h3>
|
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
|
||||||
<div v-else class="fee-heading-copy">
|
<div v-else class="fee-heading-copy">
|
||||||
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
||||||
<div>
|
<div>
|
||||||
<h3 id="inbound-fee-title">入库费用快照</h3>
|
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
|
||||||
<p>测量审核后的头程费用,人工确认后才发布。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fee-heading-actions">
|
<div class="fee-heading-actions">
|
||||||
@@ -470,7 +470,6 @@ defineExpose({ reload: loadFee })
|
|||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
||||||
<p class="empty-note">测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p>
|
|
||||||
</el-empty>
|
</el-empty>
|
||||||
|
|
||||||
<div v-else-if="snapshot" class="fee-content">
|
<div v-else-if="snapshot" class="fee-content">
|
||||||
@@ -504,7 +503,7 @@ defineExpose({ reload: loadFee })
|
|||||||
|
|
||||||
<section class="calculation-section" aria-labelledby="calculation-title">
|
<section class="calculation-section" aria-labelledby="calculation-title">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
|
<div><h4 id="calculation-title">计费依据</h4></div>
|
||||||
<div class="calculation-tags">
|
<div class="calculation-tags">
|
||||||
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
|
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
|
||||||
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
|
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ onMounted(() => void load())
|
|||||||
<template>
|
<template>
|
||||||
<section class="fees-panel admin-density" aria-label="柜次费用维护">
|
<section class="fees-panel admin-density" aria-label="柜次费用维护">
|
||||||
<header class="fees-header">
|
<header class="fees-header">
|
||||||
<div><h2>{{ title }}</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
<div><h2>{{ title }}</h2></div>
|
||||||
<div class="fees-header__actions">
|
<div class="fees-header__actions">
|
||||||
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
|
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
|
||||||
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
|
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
|
||||||
@@ -453,7 +453,7 @@ onMounted(() => void load())
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fees-panel { min-width: 0; overflow: hidden; padding-top: 0; border-top: 0; }
|
.fees-panel { min-width: 0; overflow: hidden; padding-top: 0; border-top: 0; }
|
||||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: 0 0 var(--admin-space-5); }
|
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: 0 0 var(--admin-space-5); }
|
||||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.fees-header p, .allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||||
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: var(--admin-space-4); }
|
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||||
.customer-context > span { color: var(--cc-muted); font-size: var(--admin-font-body); white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
.customer-context > span { color: var(--cc-muted); font-size: var(--admin-font-body); white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
||||||
.fees-table-title { display: flex; align-items: baseline; gap: var(--admin-space-3); }.fees-table-title strong { font-size: var(--admin-font-section); }.fees-table-title span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
.fees-table-title { display: flex; align-items: baseline; gap: var(--admin-space-3); }.fees-table-title strong { font-size: var(--admin-font-section); }.fees-table-title span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
|
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
|
||||||
<div v-if="!embedded" class="detail-page-header__identity">
|
<div v-if="!embedded" class="detail-page-header__identity">
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和操作历史</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||||
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
|
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
|
||||||
@@ -256,7 +255,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
.detail-page-header__back { margin-right: 2px; }
|
.detail-page-header__back { margin-right: 2px; }
|
||||||
.detail-page-header__identity { min-width: 0; }
|
.detail-page-header__identity { min-width: 0; }
|
||||||
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||||
.detail-page-header__identity p { margin: 3px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
|
||||||
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
|
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
|
||||||
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2, 6px) 28px; padding: var(--admin-space-4, 10px) 0 2px; border-top: 1px solid var(--cc-border); }
|
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2, 6px) 28px; padding: var(--admin-space-4, 10px) 0 2px; border-top: 1px solid var(--cc-border); }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { appointmentReturnTarget, TripAppointmentPanel } from '@/modules/appointment-calendar'
|
import { appointmentReturnTarget, TripAppointmentPanel } from '@/modules/appointment-calendar'
|
||||||
import { inquiryReturnTarget } from '../inquiryNavigation'
|
import { inquiryReturnTarget } from '../inquiryNavigation'
|
||||||
@@ -206,8 +207,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
|||||||
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
|
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
|
||||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||||
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
|
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
|
||||||
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button><InlineHelp title="操作说明"><p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p></InlineHelp></div>
|
||||||
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
|
||||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,10 +98,9 @@ describe('native whole-vehicle dispatch API', () => {
|
|||||||
for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then<unknown>(() => invoke(invalid))).rejects.toThrow('版本')
|
for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then<unknown>(() => invoke(invalid))).rejects.toThrow('版本')
|
||||||
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
|
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
|
||||||
})
|
})
|
||||||
it('updates only an existing appointment reference and its explicit version', async () => {
|
it('updates vehicle resources and timing with the explicit dispatch version', async () => {
|
||||||
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: 0 } }
|
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
||||||
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
|
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
|
||||||
await expect(api.updateDispatch('PC/1', { ...request, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: null as unknown as Version } })).rejects.toThrow('版本')
|
|
||||||
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
|
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
|
||||||
})
|
})
|
||||||
it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
|
it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const getDispatchCommand = (dispatchNo: string, key: string) => get<Comma
|
|||||||
export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
|
export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
|
||||||
export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
|
export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
|
||||||
versioned(body)
|
versioned(body)
|
||||||
if (body.vehicleAppointment) requireVersion(body.vehicleAppointment.recordVersion)
|
|
||||||
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(path(dispatchNo), body)))
|
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(path(dispatchNo), body)))
|
||||||
}
|
}
|
||||||
export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
|
export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { expect, it } from 'vitest'
|
|||||||
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||||||
import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
|
import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
|
||||||
import { destination } from '../dispatchTestFixtures'
|
import { destination } from '../dispatchTestFixtures'
|
||||||
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}` }) as CargoLine
|
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}`, sourceOrigin: null }) as CargoLine
|
||||||
function values() {
|
function values() {
|
||||||
const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
|
const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
|
||||||
const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
|
const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
|
||||||
|
|||||||
@@ -26,29 +26,32 @@ function required(index: number, preview: PreviewGroup, service: string) {
|
|||||||
defineExpose({ covered })
|
defineExpose({ covered })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group">
|
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
|
||||||
<template v-if="groups[index]">
|
<template v-if="groups[index]">
|
||||||
<h3>{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
|
<section class="density-dialog-section" data-tone="source" :aria-label="'派送分组依据 ' + (index + 1)"><h3 class="density-dialog-section__title">{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
|
||||||
<p v-for="reason in previewGroup.blockReasons" :key="reason" role="alert">{{ reason }}</p>
|
<el-alert v-for="reason in previewGroup.blockReasons" :key="reason" :title="reason" type="warning" :closable="false" show-icon />
|
||||||
<div class="arrange-grid">
|
<el-table :data="previewGroup.cargo" border><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" show-overflow-tooltip /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
|
||||||
<el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item>
|
<template v-if="previewGroup.externalQuotes.length"><h4 class="density-dialog-section__title">外接约定运费</h4><table class="density-table"><thead><tr><th>外接预订号</th><th>入仓码</th><th>币种</th><th>金额</th></tr></thead><tbody><tr v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo"><td>{{ quote.bookingNo }}</td><td>{{ quote.inboundCode }}</td><td>{{ quote.currency }}</td><td>{{ quote.amount }}</td></tr></tbody></table></template>
|
||||||
<el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item>
|
<h4 class="density-dialog-section__title">原费用覆盖</h4><p v-if="!previewGroup.feeSources.length" class="density-dialog-note">暂无可引用原费用。</p>
|
||||||
<el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item>
|
<table v-else class="density-table"><thead><tr><th>覆盖服务</th><th>币种</th><th>金额</th><th>状态</th></tr></thead><tbody><tr v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><td><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></td><td>{{ fee.currency }}</td><td>{{ fee.amount }}</td><td>{{ fee.status }}</td></tr></tbody></table>
|
||||||
<el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item>
|
</section>
|
||||||
<el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item>
|
<section class="density-dialog-section" data-tone="work" :aria-label="'派送分组登记 ' + (index + 1)"><h3 class="density-dialog-section__title">派送信息</h3>
|
||||||
<el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item>
|
<table class="density-table density-table--editable arrange-editor-table"><tbody>
|
||||||
<el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" placeholder="请输入卖家姓名" /></el-form-item><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item>
|
<tr><td><el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item></td><td><el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item></td></tr>
|
||||||
</div>
|
<tr><td><el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item></td><td><el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item></td></tr>
|
||||||
<el-table :data="previewGroup.cargo" border max-height="240"><el-table-column prop="orderNo" label="来源订单" min-width="150" /><el-table-column prop="inboundCode" label="入仓码" min-width="150" /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
|
<tr><td><el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item></td><td><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item></td></tr>
|
||||||
<p v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.inboundCode }}:{{ quote.currency }} {{ quote.amount }}(外接约定运费)</p>
|
<tr><td><el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item></td><td><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item></td></tr>
|
||||||
<h4>原费用覆盖</h4><p v-if="!previewGroup.feeSources.length">暂无可引用原费用。</p>
|
<tr><td><el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item></td><td><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" placeholder="请输入卖家姓名" /></el-form-item></td></tr>
|
||||||
<div v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><span>{{ fee.currency }} {{ fee.amount }} · {{ fee.status }}</span><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></div>
|
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item></td></tr>
|
||||||
<h4>本组新增费用</h4><div class="arrange-grid"><template v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="`${serviceLabels[fee.feeType] || fee.feeName}(未覆盖部分)`" required><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item><p v-else>本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></template></div>
|
</tbody></table>
|
||||||
|
<h4 class="density-dialog-section__title">本组新增费用</h4><table class="density-table density-table--editable"><tbody><tr v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><td><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="(serviceLabels[fee.feeType] || fee.feeName) + '(未覆盖部分)'" required><div class="arrange-fee-controls"><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" controls-position="right" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></div></el-form-item><p v-else class="density-dialog-note">本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></td></tr></tbody></table>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.arrange-group { padding-bottom: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
.arrange-editor-table { table-layout: fixed; }
|
||||||
.arrange-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
.arrange-editor-table td:not([colspan]) { width: 50%; }
|
||||||
.arrange-grid :deep(.el-select), .arrange-grid :deep(.el-date-editor), .arrange-grid :deep(.el-input-number) { width: 100%; }
|
.arrange-fee-controls { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 120px; gap: var(--admin-space-3); }
|
||||||
|
.arrange-group h4 { margin-top: var(--admin-space-4); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
||||||
@@ -70,16 +73,21 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { i
|
|||||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="dispatch-evidence" :aria-label="label"><h4>{{ label }}</h4>
|
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}</h4>
|
||||||
<p v-if="error" role="alert">{{ error.message }} <el-button link @click="load">重新读取资料</el-button></p><ApiErrorMeta v-if="error" :details="error" />
|
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<ul><li v-for="id in ids" :key="id"><span>{{ metadata[id]?.fileName || '附件资料待核实' }}</span> <el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></li></ul>
|
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||||
<p v-if="!ids.length">暂无本趟附件。</p><template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="`上传${label}`" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button><p>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
|
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||||
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="900px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><p v-if="previewLoading">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></AppDialog>
|
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button><p class="density-dialog-note">PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
|
||||||
|
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||||
|
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
|
||||||
|
</AppDialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dispatch-evidence { padding: var(--admin-space-4) 0; }
|
.dispatch-evidence-table { table-layout: fixed; }
|
||||||
.dispatch-evidence ul { margin: 0; padding-left: 20px; }
|
.dispatch-evidence__actions { width: 190px; }
|
||||||
|
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||||||
|
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||||
.evidence-input { display: none; }
|
.evidence-input { display: none; }
|
||||||
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
|
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ const can = vi.hoisted(() => vi.fn(() => true))
|
|||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||||
it('renders known business labels for existing master resources without falling back to their internal IDs', async () => {
|
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
|
||||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
|
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
|
||||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||||
expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
|
expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('resets only the resource being switched and keeps external resource choices independent', async () => {
|
it.each([false, true])('resets only the switched resource and keeps choices independent (continuous=%s)', async continuous => {
|
||||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
|
||||||
const state = wrapper.vm as unknown as { changeDriver(): void }; state.changeDriver(); await flushPromises()
|
const state = wrapper.vm as unknown as { changeDriver(): void }; state.changeDriver(); await flushPromises()
|
||||||
expect(wrapper.emitted('update:driver')![0]![0]).toMatchObject({ mode: 'EXTERNAL', driverId: null, name: null, evidence: { fileIds: [], licenseNo: null } })
|
expect(wrapper.emitted('update:driver')![0]![0]).toMatchObject({ mode: 'EXTERNAL', driverId: null, name: null, evidence: { fileIds: [], licenseNo: null } })
|
||||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(value.vehicle.vehicleId).toBe('vehicle-1'); wrapper.unmount()
|
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(value.vehicle.vehicleId).toBe('vehicle-1'); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('requires each real directory permission before reading candidates', async () => {
|
it.each([false, true])('requires each directory permission before reading candidates (continuous=%s)', async continuous => {
|
||||||
can.mockReturnValue(false); const value = resources()
|
can.mockReturnValue(false); const value = resources()
|
||||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
|
||||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
|
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
|
||||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
|
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|||||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||||
const driver = defineModel<Driver>('driver', { required: true })
|
const driver = defineModel<Driver>('driver', { required: true })
|
||||||
defineProps<{ disabled: boolean }>()
|
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||||
let vehicleSequence = 0, driverSequence = 0
|
let vehicleSequence = 0, driverSequence = 0
|
||||||
async function searchVehicles(keyword = '') {
|
async function searchVehicles(keyword = '') {
|
||||||
@@ -32,6 +32,21 @@ function selectDriver(id: string) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
<template v-if="continuous">
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||||
|
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
|
||||||
|
<tr><td><el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||||
|
<template v-if="vehicle.mode === 'EXTERNAL'">
|
||||||
|
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||||
|
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||||
|
</template>
|
||||||
|
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
|
||||||
|
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
|
||||||
|
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" class="density-dialog-note" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<p v-if="error" role="alert">{{ error }}</p>
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
<div class="resource-grid">
|
<div class="resource-grid">
|
||||||
<el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
|
<el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
|
||||||
@@ -46,8 +61,11 @@ function selectDriver(id: string) {
|
|||||||
<el-form-item v-if="driver.evidence" label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
|
<el-form-item v-if="driver.evidence" label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
|
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.dispatch-resource-table { table-layout: fixed; }
|
||||||
|
.dispatch-resource-table td:not([colspan]) { width: 50%; }
|
||||||
.resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
.resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||||
.resource-grid :deep(.el-select), .resource-grid :deep(.el-date-editor), .resource-grid :deep(.el-input-number) { width: 100%; }
|
.resource-grid :deep(.el-select), .resource-grid :deep(.el-date-editor), .resource-grid :deep(.el-input-number) { width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||||
|
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||||||
|
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||||
|
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import * as api from '../api/dispatches'
|
||||||
|
import { command, detail, destination, loadingPreview, page, resources, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||||
|
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchCargoItem, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
|
||||||
|
|
||||||
|
const identity = vi.hoisted(() => ({ id: '' }))
|
||||||
|
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||||
|
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||||
|
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||||
|
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||||
|
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
|
||||||
|
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
|
||||||
|
function item(id: string, picked = 0): DispatchCargoItem { return { dispatchItemId: id, customerId: 'C1', customerName: '客户甲', sourceKey: 'source-1', sourceVersion: 'source-v1', sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', palletNo: 'PALLET-1', orderNo: 'ORDER-1', inboundCode: 'IN-1', destination: destination(), scheduledPackages: 2, pickedPackages: picked, remove: picked ? { allowed: false, blockCode: 'PICKED', blockReason: '已经扫码,不可移除' } : gate } }
|
||||||
|
function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } }
|
||||||
|
function arrangement(): ArrangementPreview {
|
||||||
|
const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })
|
||||||
|
const first = group('A', 'C1'), second = group('B', 'C2')
|
||||||
|
first.feeSources = [{ financeFeeId: 'original-fee', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 0, sourceHash: 'original-fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE'], sourceKeys: ['A'] }]
|
||||||
|
second.externalQuotes = [{ customerId: 'C2', groupKey: 'B', bookingNo: 'BOOK-2', inboundCode: 'IN-B', currency: 'USD', amount: 200, sourceHash: 'quote-hash' }]
|
||||||
|
return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', arrange: gate, groups: [first, second], arrangement: null }
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
|
||||||
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||||
|
URL.createObjectURL = vi.fn().mockReturnValue('blob:owned-dispatch'); URL.revokeObjectURL = vi.fn()
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, actions }))
|
||||||
|
vi.mocked(api.getDispatchCommand).mockRejectedValue(new ApiResponseError('not found', 'A0404', 404))
|
||||||
|
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
vi.mocked(api.reviewDispatch).mockImplementation(async (_, body) => command('DISPATCH_REVIEW', body.idempotencyKey))
|
||||||
|
vi.mocked(api.reviewDispatchLoading).mockImplementation(async (_, body) => command('LOADING_REVIEW', body.idempotencyKey))
|
||||||
|
vi.mocked(api.cancelDispatch).mockImplementation(async (_, body) => command('CANCEL', body.idempotencyKey))
|
||||||
|
vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey))
|
||||||
|
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
|
||||||
|
vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey))
|
||||||
|
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => command('ADD_CARGO', body.idempotencyKey))
|
||||||
|
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => command('REMOVE_CARGO', body.idempotencyKey))
|
||||||
|
vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
|
||||||
|
vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview())
|
||||||
|
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
|
||||||
|
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
|
||||||
|
vi.mocked(api.listDispatches).mockResolvedValue(page())
|
||||||
|
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
|
||||||
|
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
|
||||||
|
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
|
||||||
|
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||||
|
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
|
||||||
|
})
|
||||||
|
async function render(action: DispatchAction, operation?: 'ROUTE', files = false) {
|
||||||
|
const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(initial)
|
||||||
|
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||||
|
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
|
||||||
|
}
|
||||||
|
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||||
|
|
||||||
|
describe('dispatch workflow native layout', () => {
|
||||||
|
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
|
||||||
|
const { dialog } = await render('EDIT')
|
||||||
|
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
||||||
|
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
|
||||||
|
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
||||||
|
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
||||||
|
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
||||||
|
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('维护备注')
|
||||||
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||||
|
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
|
||||||
|
})
|
||||||
|
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
|
||||||
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
const { wrapper, dialog } = await render('EDIT')
|
||||||
|
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
|
||||||
|
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('保留本次输入')
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
|
||||||
|
expect(dialog.text()).toContain('workflow-request')
|
||||||
|
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
|
||||||
|
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
||||||
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||||
|
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
|
||||||
|
expect((dialog.get('textarea[placeholder="请输入本次备注(选填)"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
|
||||||
|
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||||
|
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
|
||||||
|
})
|
||||||
|
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
|
||||||
|
const { dialog } = await render(action)
|
||||||
|
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
|
||||||
|
expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
|
||||||
|
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
|
||||||
|
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||||
|
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
|
||||||
|
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
|
||||||
|
const { dialog } = await render(action)
|
||||||
|
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
||||||
|
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview
|
||||||
|
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
||||||
|
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0]!.findAll('button')[0]!.attributes('disabled')).toBeDefined()
|
||||||
|
await rows[1]!.findAll('button').find(button => button.text() === '上移')!.trigger('click'); await flushPromises()
|
||||||
|
await dialog.get('textarea').setValue(' 顺序调整 ')
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
|
||||||
|
})
|
||||||
|
it('removes the real item identity while keeping scanned cargo unavailable', async () => {
|
||||||
|
const { dialog } = await render('REMOVE_CARGO')
|
||||||
|
const radios = dialog.get('[aria-label="本车可移除货物"]').findAllComponents({ name: 'ElRadio' })
|
||||||
|
expect(radios[1]!.props('disabled')).toBe(true)
|
||||||
|
radios[0]!.vm.$emit('update:modelValue', '9223372036854775001'); await dialog.get('textarea').setValue(' 计划变更 ')
|
||||||
|
expect(dialog.text()).not.toContain('9223372036854775001')
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9223372036854775001', { recordVersion: '9007199254740993', idempotencyKey: expect.any(String), reason: '计划变更' })
|
||||||
|
})
|
||||||
|
it('adds the complete filtered shared-pallet group and keeps every source visible when expanded', async () => {
|
||||||
|
const [first, second] = sharedPalletCandidates()
|
||||||
|
const { dialog } = await render('ADD_CARGO')
|
||||||
|
const source = dialog.get('details')
|
||||||
|
await source.get('summary').trigger('click'); await flushPromises()
|
||||||
|
expect(source.findAll('li')).toHaveLength(2)
|
||||||
|
await dialog.get('input[type="checkbox"]').setValue(true); await flushPromises()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), cargo: [first!.selection, second!.selection], batchAllocations: [] })
|
||||||
|
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('keeps references separate from real loading measurements and submits the exact manifest', async () => {
|
||||||
|
const { dialog } = await render('LOADING_COMPLETE')
|
||||||
|
const numbers = dialog.get('[aria-label="真实实载登记"]').findAllComponents({ name: 'ElInputNumber' })
|
||||||
|
expect(numbers[1]!.props('modelValue')).toBeNull()
|
||||||
|
expect(dialog.get('[aria-label="实载与参考依据"]').text()).toContain('仅供参考')
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.completeDispatchLoading).not.toHaveBeenCalled()
|
||||||
|
numbers[0]!.vm.$emit('update:modelValue', 1); numbers[1]!.vm.$emit('update:modelValue', 120); numbers[2]!.vm.$emit('update:modelValue', 1.2); await flushPromises()
|
||||||
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' })
|
||||||
|
})
|
||||||
|
it('keeps grouped delivery fields, explicit old-fee coverage and only actual uncovered charges', async () => {
|
||||||
|
const { dialog } = await render('ARRANGE_DELIVERY')
|
||||||
|
const sections = dialog.findAll('[data-tone="work"]').filter(section => section.attributes('aria-label')?.startsWith('派送分组登记'))
|
||||||
|
expect(sections).toHaveLength(2)
|
||||||
|
const groups = dialog.getComponent(DispatchArrangeFields).props('modelValue')
|
||||||
|
await dialog.get('[aria-label="派送分组依据 1"] input[type="checkbox"]').setValue(true); await flushPromises()
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const section = sections[index]!
|
||||||
|
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'ONE_PICKUP_ONE_DROP'], ['装车类型', index ? 'PALLET' : 'FLOOR']] as const) field(section, label).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||||
|
field(section, '预计派送时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
|
||||||
|
}
|
||||||
|
field(sections[0]!, '预约账号').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'BUY_APPOINTMENT'); await flushPromises()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
|
||||||
|
await field(sections[0]!, '卖家姓名').get('input').setValue('卖家甲')
|
||||||
|
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
|
||||||
|
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CNY')
|
||||||
|
field(sections[0]!, '买约费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40); await flushPromises()
|
||||||
|
expect(groups[0]!.feeCoverage).toEqual([{ financeFeeId: 'original-fee', sourceVersion: 0, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }])
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.transferDispatchToDelivery).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', groups: [expect.objectContaining({ groupKey: 'A', sellerName: '卖家甲', feeCoverage: groups[0]!.feeCoverage, fees: [expect.objectContaining({ feeType: 'LOADING_FEE', amount: 25, currency: 'CNY' }), expect.objectContaining({ feeType: 'APPOINTMENT_FEE', amount: 40, currency: 'USD' })] }), expect.objectContaining({ groupKey: 'B', fees: [] })] }))
|
||||||
|
})
|
||||||
|
it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => {
|
||||||
|
let resolve!: (value: DispatchEvidenceFile) => void
|
||||||
|
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
|
||||||
|
const { dialog } = await render('EDIT', undefined, true)
|
||||||
|
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
|
||||||
|
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
|
||||||
|
const image = files.findComponent(AppDialog)
|
||||||
|
expect(image.props('modelValue')).toBe(true)
|
||||||
|
expect(image.get('img').attributes('src')).toBe('blob:owned-dispatch')
|
||||||
|
await image.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
await image.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-dispatch')
|
||||||
|
const input = files.get('input[type="file"]'), file = new File(['png'], 'new.png', { type: 'image/png' })
|
||||||
|
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises()
|
||||||
|
expect(dialog.props('confirmLoading')).toBe(true)
|
||||||
|
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||||
|
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||||
|
resolve(evidence('302')); await flushPromises()
|
||||||
|
expect(dialog.props('confirmLoading')).toBe(false)
|
||||||
|
expect(files.props('modelValue')).toEqual(['301', '302'])
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: expect.objectContaining({ evidenceFileIds: ['301', '302'] }) }))
|
||||||
|
})
|
||||||
|
it('keeps server gates and write permissions independent from readable facts', async () => {
|
||||||
|
can.mockReturnValue(false)
|
||||||
|
const { dialog } = await render('EDIT')
|
||||||
|
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||||
|
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
|||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||||
import * as api from '../api/dispatches'
|
import * as api from '../api/dispatches'
|
||||||
import { candidate, command, detail, destination, loadingPreview, page } from '../dispatchTestFixtures'
|
import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||||
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
|
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
|
||||||
const identity = vi.hoisted(() => ({ id: '' }))
|
const identity = vi.hoisted(() => ({ id: '' }))
|
||||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||||
@@ -13,12 +13,28 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
|||||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||||
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
|
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
|
||||||
|
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
|
||||||
|
const [first, second] = sharedPalletCandidates()
|
||||||
|
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||||
|
const { wrapper, state } = await open('ADD_CARGO')
|
||||||
|
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, checked: boolean): void }
|
||||||
|
selection.selectCargo(first!, true)
|
||||||
|
first!.selectionGroup = { ...first!.selectionGroup, groupVersion: 'composition-2' }
|
||||||
|
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('组成已变化')
|
||||||
|
first!.selectionGroup.complete = false; selection.selectCargo(first!, false)
|
||||||
|
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整')
|
||||||
|
first!.selectionGroup.complete = true
|
||||||
|
selection.selectCargo(first!, true)
|
||||||
|
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||||
|
await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] }))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
|
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
|
||||||
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
|
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
|
||||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||||
const { wrapper, state } = await open('ADD_CARGO')
|
const { wrapper, state } = await open('ADD_CARGO')
|
||||||
try {
|
try {
|
||||||
(wrapper.vm as unknown as { selected: string[] }).selected = [value.sourceKey]
|
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true)
|
||||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||||
await state.submit()
|
await state.submit()
|
||||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] }))
|
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] }))
|
||||||
@@ -29,7 +45,7 @@ it('blocks cargo addition when the authoritative batch version is absent', async
|
|||||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||||
const { wrapper, state } = await open('ADD_CARGO')
|
const { wrapper, state } = await open('ADD_CARGO')
|
||||||
try {
|
try {
|
||||||
(wrapper.vm as unknown as { selected: string[] }).selected = [value.sourceKey]; await state.submit()
|
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit()
|
||||||
expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
|
expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
|
||||||
} finally { wrapper.unmount() }
|
} finally { wrapper.unmount() }
|
||||||
})
|
})
|
||||||
@@ -89,7 +105,7 @@ it('blocks an action without a literal service allowance', async () => {
|
|||||||
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
|
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
|
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
|
||||||
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2 } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
|
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, sourceOrigin: null } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
|
||||||
vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
|
vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
|
||||||
const { wrapper, state } = await open('ARRANGE_DELIVERY')
|
const { wrapper, state } = await open('ARRANGE_DELIVERY')
|
||||||
const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
|
const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import * as api from '../api/dispatches'
|
import * as api from '../api/dispatches'
|
||||||
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||||
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||||
import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts'
|
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||||||
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
|
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
|
||||||
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||||
@@ -30,13 +32,14 @@ const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<Dis
|
|||||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
|
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
|
||||||
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
|
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
|
||||||
const candidates = ref<Candidate[]>([]), selected = ref<string[]>([]), candidateKeyword = ref('')
|
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
|
||||||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
let editorRequestKey = ''
|
||||||
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||||
@@ -45,6 +48,7 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||||
|
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
|
||||||
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
||||||
items.value = null; itemId.value = ''; route.value = null; stops.value = []
|
items.value = null; itemId.value = ''; route.value = null; stops.value = []
|
||||||
try {
|
try {
|
||||||
@@ -110,6 +114,15 @@ function requirePlanPallet(row: Candidate): string {
|
|||||||
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
||||||
return row.selection.planPalletId
|
return row.selection.planPalletId
|
||||||
}
|
}
|
||||||
|
function isCargoSelected(row: Candidate): boolean { return selected.value.some(value => value.groupKey === row.selectionGroup?.groupKey) }
|
||||||
|
function selectCargo(row: Candidate, checked: unknown) {
|
||||||
|
try {
|
||||||
|
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
|
||||||
|
const group = requireSelectionGroup(row)
|
||||||
|
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
|
||||||
|
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
|
||||||
|
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
|
||||||
|
}
|
||||||
function payload(): object {
|
function payload(): object {
|
||||||
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
|
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
|
||||||
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
|
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
|
||||||
@@ -126,10 +139,14 @@ function payload(): object {
|
|||||||
}
|
}
|
||||||
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||||
case 'ADD_CARGO': {
|
case 'ADD_CARGO': {
|
||||||
const chosen = candidates.value.filter(row => selected.value.includes(row.sourceKey))
|
const chosen = selected.value.map(value => {
|
||||||
|
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
|
||||||
|
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
|
||||||
|
return row
|
||||||
|
})
|
||||||
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
|
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
|
||||||
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
|
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
|
||||||
return { ...common, cargo: chosen.map(row => row.selection), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
|
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
|
||||||
}
|
}
|
||||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||||
@@ -181,7 +198,10 @@ async function submit() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||||
if (!pending.value) recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
|
if (!pending.value) {
|
||||||
|
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
|
||||||
|
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||||
|
}
|
||||||
const routeCommand = pending.value?.operation === 'ROUTE'
|
const routeCommand = pending.value?.operation === 'ROUTE'
|
||||||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
@@ -193,24 +213,61 @@ 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 })
|
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]" :width="width" :confirm-loading="busy" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||||
<p>排车单号 {{ currentNo || '—' }}</p><p v-if="pending" role="status">原操作结果尚待核对,当前使用原版本和原请求内容。</p>
|
<div class="density-dialog-sections">
|
||||||
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<el-alert v-if="pending" role="status" title="原操作结果尚待核对,当前使用原版本和原请求内容。" type="warning" :closable="false" show-icon />
|
||||||
<el-button v-if="pending?.rejected" :disabled="busy" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
|
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<p v-if="detail?.actions[currentAction]?.allowed !== true && !pending">{{ detail?.actions[currentAction]?.blockReason || '当前操作资格未返回,请刷新核对' }}</p>
|
<el-button v-if="pending?.rejected" :disabled="busy || loading || uploadingEvidence" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
|
||||||
<el-form v-if="detail" class="density-form" label-position="top">
|
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||||
<template v-if="currentOperation === 'ROUTE' && route"><p>起运仓:{{ route.stops[0]?.name || '—' }} · {{ route.stops[0]?.address || '—' }}</p><p>{{ route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。' }}</p><ol><li v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index">{{ stop.name }} · {{ stop.address || '—' }} <el-button :disabled="frozen || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></li></ol></template><template v-else-if="currentAction === 'REMOVE_CARGO' && items"><el-table :data="items.items" border max-height="420"><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="来源订单" /><el-table-column prop="inboundCode" label="入仓码" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="scheduledPackages" label="本次箱数" /><el-table-column prop="pickedPackages" label="真实已拣" /><el-table-column label="移货资格"><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table></template><template v-else-if="currentAction === 'EDIT'"><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><div class="form-grid"><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></div></template>
|
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'"><p>{{ detail.vehicleInfo.vehicle?.plate || '—' }} · {{ detail.vehicleInfo.driver?.name || '—' }} · {{ detail.counts.palletCount }}托/{{ detail.counts.packageCount }}箱</p><p v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status }}</p><p>请核对本车资源与本趟证据;审核通过不代替后续现场扫码。</p><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></template>
|
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||||||
<template v-else-if="currentAction === 'ADD_CARGO'"><div class="density-actions"><el-input v-model="candidateKeyword" :disabled="frozen" placeholder="订单号/入仓码" /><el-button :disabled="frozen" @click="searchCargo">查询</el-button></div><el-table :data="candidates" border max-height="380"><el-table-column label="选择" width="70"><template #default="{ row }"><el-checkbox :model-value="selected.includes(row.sourceKey)" @change="selected = $event === true ? [...selected, row.sourceKey] : selected.filter(key => key !== row.sourceKey)" :disabled="frozen || row.blockReasons.length > 0" :aria-label="`选择货物 ${row.palletNo || row.orderNo || '待核实'}`" /></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="订单号" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="availablePackages" label="可排箱数" /><el-table-column label="目的仓"><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table></template>
|
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
|
||||||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview"><p>{{ preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。' }}</p><el-table :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" /><el-table-column label="实际托位"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen" :min="1" placeholder="请确认" /></template></el-table-column><el-table-column label="实测重量(kg)"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="实测体积(m³)"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="已匹配卸货顺序"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table><p v-for="pallet in preview.pallets" :key="pallet.warehousePalletId">{{ pallet.palletNo }}:<span v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '实测依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }} {{ evidence.value ?? '—' }};</span></p></template>
|
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
|
||||||
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen" />
|
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
|
||||||
<p v-else-if="currentAction === 'DEPART'">确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。</p><p v-else-if="currentAction === 'RETURN_TO_REVIEW'">仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。</p><p v-else-if="currentAction === 'CANCEL'">确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。</p>
|
</section>
|
||||||
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)"><DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen" @busy="evidenceBusy.VEHICLE = $event" /><DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen" @busy="evidenceBusy.DRIVER = $event" /></template><el-form-item v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item>
|
<section v-else-if="currentAction === 'REMOVE_CARGO' && items" class="density-dialog-section" data-tone="source" aria-label="本车可移除货物"><h3 class="density-dialog-section__title">本车货物</h3>
|
||||||
<el-form-item v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" label="备注"><el-input v-model="form.remark" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item>
|
<el-table :data="items.items" border><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || loading || uploadingEvidence || !allowed || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="scheduledPackages" label="本次箱数" width="100" /><el-table-column prop="pickedPackages" label="真实已拣" width="100" /><el-table-column label="移货资格" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table>
|
||||||
</el-form>
|
</section>
|
||||||
|
<template v-else-if="currentAction === 'EDIT'">
|
||||||
|
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||||
|
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||||
|
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||||
|
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
|
||||||
|
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。" type="info" :closable="false" show-icon />
|
||||||
|
</section>
|
||||||
|
<section 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-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
|
||||||
|
</template>
|
||||||
|
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
|
||||||
|
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
|
||||||
|
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
|
||||||
|
</section>
|
||||||
|
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
|
||||||
|
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
|
||||||
|
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
|
||||||
|
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
|
||||||
|
</template>
|
||||||
|
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen || loading || uploadingEvidence || !allowed" />
|
||||||
|
<el-alert v-else-if="currentAction === 'DEPART'" title="确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。" type="warning" :closable="false" show-icon />
|
||||||
|
<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 />
|
||||||
|
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||||||
|
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||||
|
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||||
|
</template>
|
||||||
|
<section v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_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="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item></td></tr></tbody></table></section>
|
||||||
|
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" 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" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item></td></tr></tbody></table></section>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
.workflow-editor-table { table-layout: fixed; }
|
||||||
.form-grid :deep(.el-date-editor) { width: 100%; }
|
.workflow-editor-table td { width: 50%; }
|
||||||
|
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||||
|
.workflow-cargo-query { display: flex; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
|
||||||
|
.workflow-cargo-query > .el-input { max-width: 320px; }
|
||||||
|
.workflow-source-group > summary { cursor: pointer; }
|
||||||
|
.workflow-source-group ul { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||||
|
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||||
|
import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||||
|
import * as api from '../api/dispatches'
|
||||||
|
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||||
|
import type { ConvertDraft, DispatchDraft, Vehicle } from '../dispatchContracts'
|
||||||
|
|
||||||
|
const identity = vi.hoisted(() => ({ id: '' }))
|
||||||
|
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||||
|
const push = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||||
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||||
|
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||||
|
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
|
||||||
|
const vehicleRecord: VehicleDetail = { id: '9223372036854775001', plateNumber: 'CA-MASTER', model: '档案车型', maxPalletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62, fuelConsumptionPer100Km: 10, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'operator', updatedAt: '' }
|
||||||
|
const driverRecord: DriverDetail = { id: '9223372036854775002', driverNo: 'DR-1', active: true, reviewStatus: 'APPROVED', licenseFileId: 'LICENSE-FILE', recordVersion: 0, updatedBy: 'operator', updatedAt: '', licenseState: 'VALID', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-01-01' }, details: { name: '档案司机', phone: '555-0101', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE-1', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-01-01', licenseZone: 'America/Los_Angeles', remark: null, profile: null } }
|
||||||
|
function draft(): DispatchDraft {
|
||||||
|
const first = candidate(), second = candidate({ sourceKey: 'source-2', customerId: 'C2', customerName: '客户乙', selection: { ...first.selection, planPalletId: '12', warehousePalletId: '22' }, palletNo: 'PALLET-2' })
|
||||||
|
return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [first.sourceKey] }, { draftContainerId: 'container-2', sequenceNo: 2, capacity: 15, sourceKeys: [second.sourceKey] }], sources: [first, second] }
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
|
||||||
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||||
|
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
|
||||||
|
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
|
||||||
|
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
|
||||||
|
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
|
||||||
|
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||||
|
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||||
|
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicleRecord], page: 1, size: 100, total: 1, pages: 1 })
|
||||||
|
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
|
||||||
|
})
|
||||||
|
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||||
|
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||||
|
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||||
|
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
|
||||||
|
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
|
||||||
|
async function externalResources(dialog: VueWrapper, plate: string, name: string) {
|
||||||
|
for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() }
|
||||||
|
await field(dialog, '车牌号').get('input').setValue(plate)
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate immediately after typing').toBe(plate)
|
||||||
|
await field(dialog, '拖车公司名称').get('input').setValue('本趟承运公司')
|
||||||
|
for (const [label, value] of [['实际托位容量', 17], ['最大载重(kg)', 15000], ['最大容积(m³)', 62]] as const) field(dialog, label).getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', value)
|
||||||
|
await field(dialog, '司机姓名').get('input').setValue(name); await field(dialog, '联系电话').get('input').setValue('555-0102'); await field(dialog, '驾驶证号').get('input').setValue('EXTERNAL-LICENSE')
|
||||||
|
field(dialog, '驾驶证到期日').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01')
|
||||||
|
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate after completing driver fields').toBe(plate)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('new dispatch native layout', () => {
|
||||||
|
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
|
||||||
|
const { dialog } = await render()
|
||||||
|
expect(dialog.text()).toContain('请先选择装车组')
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||||
|
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
||||||
|
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||||
|
await restore(dialog)
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
|
||||||
|
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('preserves every external field and actual capacity through fullscreen in a real empty creation', async () => {
|
||||||
|
const { wrapper, dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||||
|
await externalResources(dialog, 'CA-EXTERNAL', '临时司机')
|
||||||
|
const state = wrapper.vm as unknown as { vehicle: Vehicle }
|
||||||
|
expect(state.vehicle.plate, 'parent vehicle after external inputs').toBe('CA-EXTERNAL')
|
||||||
|
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
|
||||||
|
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
|
||||||
|
await field(dialog, '备注').get('input').setValue('本趟备注')
|
||||||
|
expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL')
|
||||||
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL')
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
||||||
|
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||||
|
expect(dialog.text()).toContain('空单不能核平、装车或发车')
|
||||||
|
expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false)
|
||||||
|
await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
|
||||||
|
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })
|
||||||
|
})
|
||||||
|
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
|
||||||
|
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||||
|
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
|
||||||
|
const vehicleSelect = field(dialog, '车辆').getComponent({ name: 'ElSelect' })
|
||||||
|
vehicleSelect.vm.$emit('focus'); await flushPromises()
|
||||||
|
vehicleSelect.vm.$emit('update:modelValue', vehicleRecord.id); vehicleSelect.vm.$emit('change', vehicleRecord.id); await flushPromises()
|
||||||
|
const driverSelect = field(dialog, '司机').getComponent({ name: 'ElSelect' })
|
||||||
|
driverSelect.vm.$emit('focus'); await flushPromises()
|
||||||
|
driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises()
|
||||||
|
expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' })
|
||||||
|
expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' })
|
||||||
|
expect(dialog.text()).not.toContain(vehicleRecord.id)
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }))
|
||||||
|
})
|
||||||
|
it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => {
|
||||||
|
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||||
|
const { wrapper, dialog } = await render(); await restore(dialog)
|
||||||
|
await externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组')
|
||||||
|
const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
|
||||||
|
radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises()
|
||||||
|
await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组')
|
||||||
|
radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises()
|
||||||
|
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A')
|
||||||
|
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
|
||||||
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
|
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
|
||||||
|
expect(request.recordVersion).toBe(0)
|
||||||
|
expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']])
|
||||||
|
expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17])
|
||||||
|
expect(wrapper.emitted('created')).toBeUndefined()
|
||||||
|
expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留')
|
||||||
|
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||||
|
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => {
|
||||||
|
let reject!: (reason: unknown) => void
|
||||||
|
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||||
|
const { wrapper, dialog } = await render(); await restore(dialog); await externalResources(dialog, 'CA-A', '司机甲')
|
||||||
|
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
|
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
||||||
|
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||||
|
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||||
|
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
||||||
|
expect(dialog.text()).toContain('new-dispatch-request')
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||||
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
|
||||||
|
})
|
||||||
|
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
|
||||||
|
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
|
||||||
|
rows[0] = sharedPalletCandidates()[0]!
|
||||||
|
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
|
||||||
|
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||||
|
const table = dialog.get('[aria-label="排车候选货物"]')
|
||||||
|
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
|
||||||
|
expect(table.text()).toContain('PALLET-24')
|
||||||
|
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
|
||||||
|
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
|
||||||
|
const sourceGroup = table.findAll('details')[0]!
|
||||||
|
await sourceGroup.get('summary').trigger('click'); await flushPromises()
|
||||||
|
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
|
||||||
|
expect(sourceGroup.findAll('li')).toHaveLength(2)
|
||||||
|
expect(sourceGroup.text()).toContain('客户A')
|
||||||
|
expect(sourceGroup.text()).toContain('客户B')
|
||||||
|
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('keeps query conditions and regional tabs on the original candidate transport', async () => {
|
||||||
|
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||||
|
await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
|
||||||
|
await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8')
|
||||||
|
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises()
|
||||||
|
await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||||
|
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined })
|
||||||
|
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
||||||
|
await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
|
||||||
|
expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
|
||||||
|
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
|
||||||
|
})
|
||||||
|
it('shows loading and initialization diagnostics without issuing create commands', async () => {
|
||||||
|
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
|
||||||
|
const { dialog } = await render()
|
||||||
|
expect(dialog.text()).toContain('draft-load-request')
|
||||||
|
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||||
|
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||||
|
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
|
||||||
|
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
|
||||||
|
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||||
|
await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises()
|
||||||
|
expect(dialog.text()).toContain('draft-rejected-request')
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||||
|
await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
|
||||||
|
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
|
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||||
|
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||||
|
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
it('keeps creation unavailable without its write permission', async () => {
|
||||||
|
can.mockReturnValue(false)
|
||||||
|
const { dialog } = await render()
|
||||||
|
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@ import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
|||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||||
import * as api from '../api/dispatches'
|
import * as api from '../api/dispatches'
|
||||||
import { candidate, command, detail } from '../dispatchTestFixtures'
|
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||||
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
|
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
|
||||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||||
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
|
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
|
||||||
@@ -17,6 +17,29 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
|||||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||||
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||||
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
|
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
|
||||||
|
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
|
||||||
|
const [first, second] = sharedPalletCandidates(), original = draft()
|
||||||
|
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
|
||||||
|
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||||
|
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
|
||||||
|
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||||
|
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||||
|
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void>; submit(): Promise<void> }
|
||||||
|
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('LOCK', first, original.containers[0]!.draftContainerId)
|
||||||
|
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first!.selection }))
|
||||||
|
await state.submit(); expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual([first!.selection, second!.selection])
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('allows one atomic removal of a restored group that is no longer selectable', async () => {
|
||||||
|
const first = sharedPalletCandidates()[0]!, original = draft()
|
||||||
|
first.selectionGroup = { ...first.selectionGroup, complete: false, blockReasons: ['来源待核齐'] }
|
||||||
|
original.sources = [first]; original.containers[0]!.sourceKeys = [first.sourceKey]
|
||||||
|
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.unlockDispatchDraftPallet).mockResolvedValue(original)
|
||||||
|
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||||
|
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void> }
|
||||||
|
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('UNLOCK', first, original.containers[0]!.draftContainerId)
|
||||||
|
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
|
||||||
|
})
|
||||||
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
|
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
|
||||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
||||||
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
|
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
|
||||||
@@ -55,12 +78,12 @@ it('requires an explicit warehouse and allows saving a true empty dispatch with
|
|||||||
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('restores an explicit draft and submits complete server source versions in each container', async () => {
|
it('restores an explicit draft and submits complete server source versions in each container', async () => {
|
||||||
const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙' })); original.containers[0]!.sourceKeys.push('source-B')
|
const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙', selection: { ...source().selection, planPalletId: '22', warehousePalletId: '32' } })); original.containers[0]!.sourceKeys.push('source-B')
|
||||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
|
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
|
||||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) }))
|
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) }))
|
||||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
|
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
|
||||||
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises()
|
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises()
|
||||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, source().selection] }) })] }))
|
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
|
||||||
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
|
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|||||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
|
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import * as api from '../api/dispatches'
|
import * as api from '../api/dispatches'
|
||||||
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
|
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||||
import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts'
|
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts'
|
||||||
import { dispatchActionPermissions } from '../dispatchPresentation'
|
import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||||
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
|
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
|
||||||
import type { InboundOrderListItem } from '../types'
|
import type { InboundOrderListItem } from '../types'
|
||||||
@@ -36,7 +38,8 @@ const visibleCandidates = computed(() => candidates.value.filter(row => matchesS
|
|||||||
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
|
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
|
||||||
const currentSettings = computed(() => {
|
const currentSettings = computed(() => {
|
||||||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||||||
return settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
||||||
|
return settings[key]!
|
||||||
})
|
})
|
||||||
const form = computed(() => currentSettings.value.form)
|
const form = computed(() => currentSettings.value.form)
|
||||||
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
|
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
|
||||||
@@ -78,10 +81,11 @@ async function loadCandidates(sequence = generation) {
|
|||||||
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
||||||
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
|
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
|
||||||
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
||||||
|
assertSelectionGroupVersions(cargo, candidates.value)
|
||||||
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
|
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
|
||||||
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
|
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
|
||||||
const config = containerId ? settings[containerId] : currentSettings.value
|
const config = containerId ? settings[containerId] : currentSettings.value
|
||||||
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: cargo.map(row => row.selection), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||||
}
|
}
|
||||||
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
|
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
|
||||||
if (!canCreate.value || loading.value || saving.value) return
|
if (!canCreate.value || loading.value || saving.value) return
|
||||||
@@ -96,7 +100,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
|||||||
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
|
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
|
||||||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||||||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId || source.blockReasons.length) throw new DispatchInputError('请选择可排货物和装车容器'); body = { ...common, draftContainerId: containerId, source: source.selection } }
|
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
||||||
else if (action === 'RELEASE') body = common
|
else if (action === 'RELEASE') body = common
|
||||||
else {
|
else {
|
||||||
@@ -162,24 +166,66 @@ async function submit() {
|
|||||||
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
|
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<div class="density-dialog-sections">
|
||||||
<p v-if="pending || recovery.pending.value" role="status">原请求结果尚待核对,请勿更改选择、版本或重新建单。</p><el-button v-if="pending?.rejected" :disabled="saving" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<el-form label-position="top" class="density-form"><div class="create-grid"><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></div>
|
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
|
||||||
<p v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? `装车组 ${draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo}` : '请先选择装车组' }};每组车辆、司机和时间独立保存。</p><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen" placeholder="请输入本趟备注(选填)" /></el-form-item>
|
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
||||||
</el-form>
|
<el-form label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
||||||
<p v-if="mode === 'empty'">空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。</p>
|
<section class="density-dialog-section" data-tone="work" aria-label="排车设置"><h3 class="density-dialog-section__title">建单设置 <small v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? '装车组 ' + draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo : '请先选择装车组' }}</small><InlineHelp v-if="mode === 'cargo'" title="装车组说明">每组车辆、司机和时间独立保存。</InlineHelp></h3>
|
||||||
<template v-else><div class="density-actions"><el-select :model-value="draft?.draftToken" :disabled="frozen" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="`${warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实'} · ${saved.containers.length}组 · ${saved.sources.length}条货物`" /></el-select><el-button :disabled="frozen || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select v-model="capacity" :disabled="frozen" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
|
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
||||||
<div class="density-actions"><el-input v-model="query.orderNo" :disabled="frozen" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || !warehouseId" @click="search">查询</el-button></div>
|
<tr><td><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item></td><td><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item></td></tr>
|
||||||
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen" @click="warehouseTab = tab.value">{{ tab.label }}</button></div><div class="cargo-workspace"><el-table :data="visibleCandidates" border height="420"><el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || !activeContainerId || row.blockReasons.length > 0" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" /><el-table-column label="来源" min-width="130"><template #default="{ row }">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}<br>{{ row.selection.relabelNo || row.planNo || '—' }}</template></el-table-column><el-table-column prop="orderNo" label="订单号" min-width="170" /><el-table-column prop="inboundCode" label="入仓码" min-width="170" /><el-table-column prop="palletNo" label="托盘码" min-width="170" /><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column><el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³<br>仅供预排参考</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" /><el-table-column label="资格" min-width="160"><template #default="{ row }">{{ row.blockReasons.join(';') || '可排' }}</template></el-table-column></el-table>
|
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
||||||
<section><article v-for="container in draft?.containers || []" :key="container.draftContainerId" class="draft-container"><el-radio v-model="activeContainerId" :disabled="frozen" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio><el-button link :disabled="frozen" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button><ul><li v-for="key in container.sourceKeys" :key="key">{{ draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复' }}<el-button link :disabled="frozen || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></article><p v-if="!draft?.containers.length">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></section>
|
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" placeholder="请输入本趟备注(选填)" /></el-form-item></td></tr>
|
||||||
</div>
|
</tbody></table>
|
||||||
</template>
|
</section>
|
||||||
|
<section class="density-dialog-section" data-tone="work" aria-label="排车车辆与司机"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></section>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-if="mode === 'empty'" title="空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
||||||
|
<template v-else>
|
||||||
|
<section class="density-dialog-section" data-tone="summary" aria-label="整车草稿管理"><h3 class="density-dialog-section__title">整车草稿</h3>
|
||||||
|
<div class="dispatch-draft-tools density-actions"><el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select><el-button :disabled="frozen || loading || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || loading || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || loading || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
|
||||||
|
</section>
|
||||||
|
<div class="cargo-workspace">
|
||||||
|
<section class="density-dialog-section" data-tone="source" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
|
||||||
|
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
|
||||||
|
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="warehouseTab = tab.value">{{ tab.label }}</button></div>
|
||||||
|
<el-table :data="visibleCandidates" border>
|
||||||
|
<el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !activeContainerId || Boolean(selectionGroupBlockReason(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column>
|
||||||
|
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
|
||||||
|
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary><div class="dispatch-group-summary"><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}个实托</span></div></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
|
||||||
|
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
<section class="density-dialog-section" data-tone="summary" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组</h3>
|
||||||
|
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table"><tbody>
|
||||||
|
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
|
||||||
|
<tr><td colspan="2"><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<p v-if="!draft?.containers.length" class="density-dialog-note">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.create-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
.dispatch-create-table { table-layout: fixed; }
|
||||||
.create-grid :deep(.el-select), .create-grid :deep(.el-date-editor) { width: 100%; }
|
.dispatch-create-table td:not([colspan]) { width: 50%; }
|
||||||
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: var(--admin-space-5); }
|
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); gap: var(--admin-space-5); align-items: start; }
|
||||||
.draft-container { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
.cargo-workspace > .density-dialog-section { margin: 0; }
|
||||||
|
.dispatch-draft-tools { flex-wrap: wrap; }
|
||||||
|
.dispatch-draft-picker { width: 320px; }
|
||||||
|
.dispatch-capacity-picker { width: 130px; }
|
||||||
|
.dispatch-candidate-query { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(130px, .8fr) auto; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
|
||||||
|
.dispatch-group-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--admin-space-2); }
|
||||||
|
.dispatch-source-group > summary { cursor: pointer; }
|
||||||
|
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
|
||||||
|
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
|
||||||
|
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
|
||||||
|
.dispatch-draft-source { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||||
|
.dispatch-draft-source :deep(.line-clamp-text) { flex: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; s
|
|||||||
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
|
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
|
||||||
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
||||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||||
export interface VehicleAppointment { referenceNo: string; status: string; appointmentAt: string | null }
|
|
||||||
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
||||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string }
|
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||||
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; vehicleAppointment: VehicleAppointment | null; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
||||||
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
||||||
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||||
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
||||||
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; vehicleAppointment?: { referenceNo: string; recordVersion: Version } | null; remark: string | null }
|
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
|
||||||
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||||
export interface ReasonCommand extends VersionCommand { reason: string }
|
export interface ReasonCommand extends VersionCommand { reason: string }
|
||||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
|
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
|
||||||
@@ -43,7 +42,10 @@ export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: n
|
|||||||
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
|
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
|
||||||
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
|
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
|
||||||
export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
|
export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
|
||||||
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[] }
|
export interface SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number }
|
||||||
|
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
|
||||||
|
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
|
||||||
|
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
|
||||||
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
|
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
|
||||||
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
|
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
|
||||||
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
|
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
|
||||||
@@ -63,7 +65,8 @@ export interface FeeItem { feeType: string; feeName: string; direction: string;
|
|||||||
export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
|
export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
|
||||||
export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
|
export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
|
||||||
export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
|
export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
|
||||||
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[] }
|
export interface SourceOrigin { dispatchItemSourceId: string | null; relabelApplicationId: string | null; relabelItemId: string | null; sourceInventoryBalanceId: string | null; sourceInboundOrderId: string | null; sourceInboundOrderNo: string | null; sourceInboundCode: string | null; productQuantity: number | null }
|
||||||
|
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[]; sourceOrigin: SourceOrigin | null }
|
||||||
export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
|
export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
|
||||||
export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
|
export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
|
||||||
interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
|
interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
|
||||||
@@ -98,3 +101,44 @@ export function requireBatchVersions(values: readonly { batchKey: string; record
|
|||||||
versions.set(value.batchKey, canonical)
|
versions.set(value.batchKey, canonical)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectionIdentity(value: CargoSelection): string {
|
||||||
|
return JSON.stringify([value.sourceType, value.planPalletId, value.returnReceiptId, value.relabelNo, value.warehousePalletId])
|
||||||
|
}
|
||||||
|
export function requireSelectionGroup(row: Candidate): SelectionGroup {
|
||||||
|
const group = row.selectionGroup
|
||||||
|
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||||
|
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
|
||||||
|
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
|
||||||
|
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
|
||||||
|
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
|
||||||
|
if (!group.sources.some(source => source.sourceKey === row.sourceKey && source.customerId === row.customerId && selectionIdentity(source.selection) === selectionIdentity(row.selection) && source.selection.sourceVersion === row.sourceVersion && row.selection.sourceVersion === row.sourceVersion)) throw new DispatchInputError('候选与完整选货组来源不一致,请重新读取候选')
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
export function selectionGroupBlockReason(row: Candidate): string {
|
||||||
|
try { requireSelectionGroup(row); return '' } catch (cause) { return cause instanceof Error ? cause.message : '完整选货组无效,请重新读取候选' }
|
||||||
|
}
|
||||||
|
export function assertSelectionGroupVersions(rows: readonly Candidate[], latest: readonly Candidate[]): void {
|
||||||
|
const versions = new Map<string, string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const group = row.selectionGroup
|
||||||
|
if (!group?.groupKey || !group.groupVersion) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||||
|
if (versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
|
||||||
|
versions.set(group.groupKey, group.groupVersion)
|
||||||
|
}
|
||||||
|
for (const row of latest) {
|
||||||
|
const group = row.selectionGroup, selected = rows.find(value => value.sourceKey === row.sourceKey)
|
||||||
|
if (selected && selected.selectionGroup.groupKey !== group?.groupKey || group && versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function expandSelectionGroups(rows: readonly Candidate[]): CargoSelection[] {
|
||||||
|
assertSelectionGroupVersions(rows, [])
|
||||||
|
const sources = new Map<string, SelectionGroupSource>(), selections = new Map<string, CargoSelection>()
|
||||||
|
for (const row of rows) for (const source of requireSelectionGroup(row).sources) {
|
||||||
|
const previous = sources.get(source.sourceKey), identity = selectionIdentity(source.selection), selected = selections.get(identity)
|
||||||
|
if (previous && (selectionIdentity(previous.selection) !== identity || previous.selection.sourceVersion !== source.selection.sourceVersion || previous.customerId !== source.customerId || previous.packages !== source.packages)) throw new DispatchInputError('完整选货组来源事实不一致,请重新读取候选')
|
||||||
|
if (selected && selected.sourceVersion !== source.selection.sourceVersion) throw new DispatchInputError('完整选货组来源版本不一致,请重新读取候选')
|
||||||
|
sources.set(source.sourceKey, source); selections.set(identity, source.selection)
|
||||||
|
}
|
||||||
|
return [...selections.values()]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
|
||||||
|
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
|
||||||
|
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
|
||||||
|
|
||||||
|
it('expands a filtered physical group into both exact relabel selections once while retaining one physical pallet', () => {
|
||||||
|
const [first, second] = sharedPalletCandidates()
|
||||||
|
expect(expandSelectionGroups([first!])).toEqual([first!.selection, second!.selection])
|
||||||
|
expect(expandSelectionGroups([first!, second!, first!])).toEqual([first!.selection, second!.selection])
|
||||||
|
expect(requireSelectionGroup(first!).pallets).toEqual([{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: 8 }])
|
||||||
|
})
|
||||||
|
it.each(['INBOUND_PLAN', 'RETURN_RECEIPT'] as const)('allows authoritative nullable business display fields for ordinary %s groups', sourceType => {
|
||||||
|
const row = candidate({ selection: { ...candidate().selection, sourceType, planPalletId: sourceType === 'INBOUND_PLAN' ? '11' : null, returnReceiptId: sourceType === 'RETURN_RECEIPT' ? '12' : null } })
|
||||||
|
expect(row.selectionGroup.sources[0]).toMatchObject({ sourceBusinessType: null, sourceBusinessNo: null })
|
||||||
|
expect(expandSelectionGroups([row])).toEqual([row.selection])
|
||||||
|
})
|
||||||
|
it('requires the actual RELABEL_ORDER business identity for relabel sources without accepting a legacy alias', () => {
|
||||||
|
const row = sharedPalletCandidates()[0]!
|
||||||
|
row.selectionGroup.sources[0]!.sourceBusinessNo = null
|
||||||
|
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
|
||||||
|
row.selectionGroup.sources[0]!.sourceBusinessNo = 'RL-A'; row.selectionGroup.sources[0]!.sourceBusinessType = 'RELABEL'
|
||||||
|
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
|
||||||
|
})
|
||||||
|
it.each([undefined, { complete: false }, { blockReasons: ['换标来源尚未核齐'] }])('rejects a missing, incomplete or blocked selection group instead of falling back to the visible row', change => {
|
||||||
|
const row = sharedPalletCandidates()[0]!
|
||||||
|
row.selectionGroup = change ? { ...row.selectionGroup, ...change } : undefined as unknown as SelectionGroup
|
||||||
|
expect(() => expandSelectionGroups([row])).toThrow(/完整选货组|尚未完整|尚未核齐/)
|
||||||
|
})
|
||||||
|
it('rejects changed group composition and conflicting source versions before creating a command', () => {
|
||||||
|
const [first, second] = sharedPalletCandidates()
|
||||||
|
expect(() => assertSelectionGroupVersions([first!], [{ ...second!, selectionGroup: { ...second!.selectionGroup, groupVersion: 'composition-2' } }])).toThrow('组成已变化')
|
||||||
|
const conflict = { ...second!, selectionGroup: { ...second!.selectionGroup, sources: second!.selectionGroup.sources.map((source, index) => index ? source : { ...source, selection: { ...source.selection, sourceVersion: 'changed' } }) } }
|
||||||
|
expect(() => expandSelectionGroups([first!, conflict])).toThrow('来源事实不一致')
|
||||||
|
})
|
||||||
|
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
|
||||||
|
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
|
||||||
|
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
|
||||||
|
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
|
||||||
|
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
|
||||||
|
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
|
||||||
|
value.selectionGroup.sources[0]!.packages = '9007199254740993'
|
||||||
|
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||||
|
})
|
||||||
@@ -3,10 +3,15 @@ export const counts = () => ({ itemCount: 1, palletCount: 1, packageCount: 2, pi
|
|||||||
export const reviews = () => ({ dispatchReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } })
|
export const reviews = () => ({ dispatchReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } })
|
||||||
export const resources = (): { vehicle: VehicleSnapshot; driver: Driver } => ({ vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-100', carrierName: 'Carrier', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 70 }, evidenceFileIds: [], vehicleStatus: null }, driver: { mode: 'EXTERNAL', driverId: null, name: '司机甲', phone: '555-0100', evidence: { licenseNo: 'TEST-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-01T10:00:00Z' } } })
|
export const resources = (): { vehicle: VehicleSnapshot; driver: Driver } => ({ vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-100', carrierName: 'Carrier', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 70 }, evidenceFileIds: [], vehicleStatus: null }, driver: { mode: 'EXTERNAL', driverId: null, name: '司机甲', phone: '555-0100', evidence: { licenseNo: 'TEST-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-01T10:00:00Z' } } })
|
||||||
export const destination = () => ({ key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null })
|
export const destination = () => ({ key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null })
|
||||||
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1' }], ...overrides } }
|
export function sharedPalletCandidates(): Candidate[] {
|
||||||
|
const rows = ['A', 'B'].map((suffix, index) => candidate({ sourceKey: `RELABEL:${suffix}`, sourceVersion: `source-${suffix}`, customerId: `C${index + 1}`, customerName: `客户${suffix}`, availablePackages: index ? 5 : 3, selection: { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: `RL-${suffix}`, warehousePalletId: '21', sourceVersion: `source-${suffix}` } }))
|
||||||
|
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
|
||||||
|
return rows.map(row => ({ ...row, selectionGroup: group }))
|
||||||
|
}
|
||||||
|
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
|
||||||
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
||||||
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
|
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
|
||||||
export function candidate(overrides: Partial<Candidate> = {}): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides } }
|
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
|
||||||
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
||||||
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
||||||
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
|
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersio
|
|||||||
const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
|
const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
|
||||||
const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
|
const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
|
||||||
|
|
||||||
|
it('keeps nullable original-source provenance distinct from the fulfillment order and normalizes only its evidenced quantity', () => {
|
||||||
|
const origin: wire.SourceOriginWire = { dispatchItemSourceId: id, relabelApplicationId: '9007199254740994', relabelItemId: '9007199254740995', sourceInventoryBalanceId: '9007199254740996', sourceInboundOrderId: '9007199254740997', sourceInboundOrderNo: 'ORIGINAL-IN-1', sourceInboundCode: null, productQuantity: '7' }
|
||||||
|
const cargo: wire.ArrangementPreviewWire['groups'][number]['cargo'][number] = { dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'FULFILLMENT-1', sourceKey: 'source-1', sourceStage: 'RELABEL', sourceRegion: null, sourceInboundId: null, inboundCode: 'CURRENT-IN-1', planPalletId: null, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: 'RL-1', destination: destination(), packages: '2', productQuantity: '24', grossWeightKg: null, volumeCbm: null, batchAllocations: [], sourceOrigin: origin }
|
||||||
|
const value: wire.ArrangementPreviewWire = { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'cargo', loadingCompletionHash: 'loading', arrange: gate, arrangement: null, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }] }
|
||||||
|
const normalized = wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!
|
||||||
|
expect(normalized.sourceOrigin).toEqual({ ...origin, productQuantity: 7 }); expect(normalized.orderId).toBe(id); expect(normalized.orderNo).toBe('FULFILLMENT-1'); expect(normalized.inboundCode).toBe('CURRENT-IN-1'); expect(normalized.productQuantity).toBe(24)
|
||||||
|
cargo.sourceOrigin = { ...origin, sourceInboundOrderNo: null, sourceInboundCode: null, productQuantity: null }
|
||||||
|
expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toEqual({ ...cargo.sourceOrigin })
|
||||||
|
cargo.sourceOrigin = null; expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toBeNull()
|
||||||
|
cargo.sourceOrigin = { ...origin, productQuantity: '9007199254740993' }
|
||||||
|
expect(() => wire.normalizeArrangementPreview(parsed(value))).toThrow('安全整数')
|
||||||
|
})
|
||||||
|
|
||||||
describe('native dispatch Long quantity boundary', () => {
|
describe('native dispatch Long quantity boundary', () => {
|
||||||
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
|
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
|
||||||
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
|
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
|
||||||
@@ -68,7 +81,7 @@ describe('native dispatch Long quantity boundary', () => {
|
|||||||
expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
|
expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
|
||||||
})
|
})
|
||||||
it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
|
it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
|
||||||
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [] }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
|
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [], sourceOrigin: null }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
|
||||||
expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
|
expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
|
||||||
})
|
})
|
||||||
it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
|
it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts } from './dispatchContracts'
|
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts, type SelectionGroup, type SourceOrigin } from './dispatchContracts'
|
||||||
|
|
||||||
// common-web serializes both Long and long as decimal strings. Only quantity
|
// common-web serializes both Long and long as decimal strings. Only quantity
|
||||||
// fields are normalized here; IDs, recordVersion and reviewRound stay lossless.
|
// fields are normalized here; IDs, recordVersion and reviewRound stay lossless.
|
||||||
@@ -17,14 +17,17 @@ type StatisticsWire = Omit<DispatchStatistics, 'matchedTotal' | 'activeTotal' |
|
|||||||
type ListRowWire = Omit<DispatchListRow, 'counts'> & { counts: CountsWire }
|
type ListRowWire = Omit<DispatchListRow, 'counts'> & { counts: CountsWire }
|
||||||
export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' | 'size' | 'pages' | 'statistics'> & { records: ListRowWire[]; total: LongQuantityWire; page: LongQuantityWire; size: LongQuantityWire; pages: LongQuantityWire; statistics: StatisticsWire }
|
export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' | 'size' | 'pages' | 'statistics'> & { records: ListRowWire[]; total: LongQuantityWire; page: LongQuantityWire; size: LongQuantityWire; pages: LongQuantityWire; statistics: StatisticsWire }
|
||||||
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
|
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
|
||||||
export type CandidateWire = LongFields<Candidate, 'availablePackages'>
|
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
|
||||||
|
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
|
||||||
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
|
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
|
||||||
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
|
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
|
||||||
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||||
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||||
export type DispatchEvidenceFileWire = LongFields<DispatchEvidenceFile, 'sizeBytes'>
|
export type DispatchEvidenceFileWire = LongFields<DispatchEvidenceFile, 'sizeBytes'>
|
||||||
type ArrangementResultWire = Omit<ArrangementResult, keyof ReferencesWire> & ReferencesWire
|
type ArrangementResultWire = Omit<ArrangementResult, keyof ReferencesWire> & ReferencesWire
|
||||||
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: Array<LongFields<CargoLine, 'packages' | 'productQuantity'>> }
|
export type SourceOriginWire = Omit<SourceOrigin, 'productQuantity'> & { productQuantity: LongQuantityWire | null }
|
||||||
|
type CargoLineWire = Omit<LongFields<CargoLine, 'packages' | 'productQuantity'>, 'sourceOrigin'> & { sourceOrigin: SourceOriginWire | null }
|
||||||
|
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: CargoLineWire[] }
|
||||||
export type ArrangementPreviewWire = Omit<ArrangementPreview, 'groups' | 'arrangement'> & { groups: PreviewGroupWire[]; arrangement: ArrangementResultWire | null }
|
export type ArrangementPreviewWire = Omit<ArrangementPreview, 'groups' | 'arrangement'> & { groups: PreviewGroupWire[]; arrangement: ArrangementResultWire | null }
|
||||||
type PickedWire = LongFields<Extract<CommandResult, { action: 'PICK' }>['result'], 'packages' | 'pickedPackages'>
|
type PickedWire = LongFields<Extract<CommandResult, { action: 'PICK' }>['result'], 'packages' | 'pickedPackages'>
|
||||||
type DepartedWire = Omit<Extract<CommandResult, { action: 'DEPART' }>['result'], 'shippedPackages' | keyof ReferencesWire> & ReferencesWire & { shippedPackages: LongQuantityWire }
|
type DepartedWire = Omit<Extract<CommandResult, { action: 'DEPART' }>['result'], 'shippedPackages' | keyof ReferencesWire> & ReferencesWire & { shippedPackages: LongQuantityWire }
|
||||||
@@ -46,7 +49,12 @@ function numbers<T, K extends keyof T>(value: LongFields<T, K>, fields: readonly
|
|||||||
const counts = (value: CountsWire) => numbers<Counts, keyof Counts>(value, ['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount'])
|
const counts = (value: CountsWire) => numbers<Counts, keyof Counts>(value, ['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount'])
|
||||||
const reviewCounts = (value: ReviewCountsWire) => numbers<ReviewCounts, keyof ReviewCounts>(value, ['notSubmitted', 'pending', 'approved', 'rejected'])
|
const reviewCounts = (value: ReviewCountsWire) => numbers<ReviewCounts, keyof ReviewCounts>(value, ['notSubmitted', 'pending', 'approved', 'rejected'])
|
||||||
const references = (value: ReferencesWire) => ({ outbounds: value.outbounds.map(row => numbers<OutboundReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])), deliveries: value.deliveries.map(row => numbers<DeliveryReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])) })
|
const references = (value: ReferencesWire) => ({ outbounds: value.outbounds.map(row => numbers<OutboundReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])), deliveries: value.deliveries.map(row => numbers<DeliveryReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])) })
|
||||||
const candidate = (value: CandidateWire): Candidate => { requireBatchVersions(value.appointmentBatches); return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages') } }
|
const candidate = (value: CandidateWire): Candidate => {
|
||||||
|
requireBatchVersions(value.appointmentBatches)
|
||||||
|
const group = value.selectionGroup
|
||||||
|
if (!group || typeof group.groupKey !== 'string' || typeof group.groupVersion !== 'string' || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||||
|
return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages'), selectionGroup: { ...group, sources: group.sources.map(source => ({ ...source, packages: dispatchQuantity(source.packages, 'selectionGroup.sources.packages') })), pallets: group.pallets.map(pallet => ({ ...pallet, packageCount: dispatchQuantity(pallet.packageCount, 'selectionGroup.pallets.packageCount') })) } }
|
||||||
|
}
|
||||||
function statistics(value: StatisticsWire): DispatchStatistics {
|
function statistics(value: StatisticsWire): DispatchStatistics {
|
||||||
return { ...value, matchedTotal: dispatchQuantity(value.matchedTotal, 'matchedTotal'), activeTotal: dispatchQuantity(value.activeTotal, 'activeTotal'), cancelledTotal: dispatchQuantity(value.cancelledTotal, 'cancelledTotal'), stageCounts: Object.fromEntries(Object.entries(value.stageCounts).map(([stage, count]) => [stage, dispatchQuantity(count, `stageCounts.${stage}`)])) as Record<DispatchStage, number>, beforeLoadingReviewCounts: { vehicleInfoTotal: dispatchQuantity(value.beforeLoadingReviewCounts.vehicleInfoTotal, 'vehicleInfoTotal'), dispatchReview: reviewCounts(value.beforeLoadingReviewCounts.dispatchReview), loadingReview: reviewCounts(value.beforeLoadingReviewCounts.loadingReview) } }
|
return { ...value, matchedTotal: dispatchQuantity(value.matchedTotal, 'matchedTotal'), activeTotal: dispatchQuantity(value.activeTotal, 'activeTotal'), cancelledTotal: dispatchQuantity(value.cancelledTotal, 'cancelledTotal'), stageCounts: Object.fromEntries(Object.entries(value.stageCounts).map(([stage, count]) => [stage, dispatchQuantity(count, `stageCounts.${stage}`)])) as Record<DispatchStage, number>, beforeLoadingReviewCounts: { vehicleInfoTotal: dispatchQuantity(value.beforeLoadingReviewCounts.vehicleInfoTotal, 'vehicleInfoTotal'), dispatchReview: reviewCounts(value.beforeLoadingReviewCounts.dispatchReview), loadingReview: reviewCounts(value.beforeLoadingReviewCounts.loadingReview) } }
|
||||||
}
|
}
|
||||||
@@ -58,7 +66,12 @@ export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems
|
|||||||
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
||||||
export function normalizeEvidenceFile(value: DispatchEvidenceFileWire): DispatchEvidenceFile { return { ...value, sizeBytes: dispatchQuantity(value.sizeBytes, 'sizeBytes') } }
|
export function normalizeEvidenceFile(value: DispatchEvidenceFileWire): DispatchEvidenceFile { return { ...value, sizeBytes: dispatchQuantity(value.sizeBytes, 'sizeBytes') } }
|
||||||
function arrangementResult(value: ArrangementResultWire): ArrangementResult { return { ...value, ...references(value) } }
|
function arrangementResult(value: ArrangementResultWire): ArrangementResult { return { ...value, ...references(value) } }
|
||||||
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity') })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
|
function normalizeSourceOrigin(value: SourceOriginWire | null): SourceOrigin | null {
|
||||||
|
if (value == null) return null
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new DispatchInputError('排车响应原货源结构缺失,请重新读取')
|
||||||
|
return { ...value, productQuantity: value.productQuantity == null ? null : dispatchQuantity(value.productQuantity, 'sourceOrigin.productQuantity') }
|
||||||
|
}
|
||||||
|
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity'), sourceOrigin: normalizeSourceOrigin(row.sourceOrigin) })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
|
||||||
export function normalizeDispatchCommand(value: CommandResultWire): CommandResult {
|
export function normalizeDispatchCommand(value: CommandResultWire): CommandResult {
|
||||||
const normalizedCounts = counts(value.counts)
|
const normalizedCounts = counts(value.counts)
|
||||||
switch (value.action) {
|
switch (value.action) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export { overseasWarehousePermissions } from './permissions'
|
|||||||
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
||||||
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
|
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
|
||||||
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
|
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
|
||||||
export { DispatchInputError, requireVersion, requireBatchVersions, stageLabel } from './dispatchContracts'
|
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
|
||||||
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
||||||
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
||||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTabl
|
|||||||
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||||
describe('whole-dispatch four-tab detail', () => {
|
describe('whole-dispatch four-tab detail', () => {
|
||||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||||
|
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||||
|
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||||
expect(getDispatch).toHaveBeenCalledWith('PC/1')
|
expect(getDispatch).toHaveBeenCalledWith('PC/1')
|
||||||
const tabs = wrapper.findAll('[role="tab"]')
|
const tabs = wrapper.findAll('[role="tab"]')
|
||||||
@@ -23,14 +25,18 @@ describe('whole-dispatch four-tab detail', () => {
|
|||||||
expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
|
expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
|
||||||
await tabs[1]!.trigger('click'); await flushPromises()
|
await tabs[1]!.trigger('click'); await flushPromises()
|
||||||
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
|
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
|
||||||
expect(wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`).text()).toContain('LAX → ONT8')
|
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
|
||||||
|
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
|
||||||
|
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
|
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
|
||||||
const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
|
const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
|
||||||
|
value.orderDetails.forEach((row, index) => { row.sourceBusinessType = 'RELABEL_ORDER'; row.sourceBusinessNo = `RL-${index + 1}` })
|
||||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||||
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
|
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
|
||||||
|
expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' }))
|
||||||
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
|
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
|
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const dispatchFields = computed(() => [
|
|||||||
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
||||||
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
||||||
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
||||||
['车辆预约', detail.value?.dispatchInfo.vehicleAppointment ? [detail.value.dispatchInfo.vehicleAppointment.referenceNo, detail.value.dispatchInfo.vehicleAppointment.status, time(detail.value.dispatchInfo.vehicleAppointment.appointmentAt)].join(' · ') : '—'],
|
|
||||||
])
|
])
|
||||||
const documentFields = computed(() => [
|
const documentFields = computed(() => [
|
||||||
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
||||||
@@ -44,6 +43,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
|
|||||||
const orderColumns: DataTableColumn[] = [
|
const orderColumns: DataTableColumn[] = [
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
|
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
|
||||||
|
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
|
||||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
|
||||||
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
|
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
|
||||||
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
|
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
|
||||||
|
|||||||
@@ -50,9 +50,8 @@ async function exportLedger() { exportLoading.value = true; try { const range =
|
|||||||
<template #toolbar-start><h1>进销存记录</h1></template>
|
<template #toolbar-start><h1>进销存记录</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||||
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||||
<template v-else><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template></template>
|
</template>
|
||||||
|
|
||||||
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
|
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
|
||||||
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
|
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
|
||||||
</ListPageLayout></AdminPage></template>
|
</ListPageLayout></AdminPage></template>
|
||||||
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
|
|
||||||
|
|||||||
@@ -258,7 +258,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
|||||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||||
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||||
expect(dialog).toContain('cargo: cargo.map(row => row.selection)')
|
// A filtered candidate must still submit its complete server-owned selection group.
|
||||||
|
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
|
||||||
|
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||||
expect(dialog).toContain('同仓可混装多个客户与目的仓')
|
expect(dialog).toContain('同仓可混装多个客户与目的仓')
|
||||||
expect(dialog).not.toContain('一次排车只能装同一客户')
|
expect(dialog).not.toContain('一次排车只能装同一客户')
|
||||||
expect(dialog).not.toContain('ensureDraftToken')
|
expect(dialog).not.toContain('ensureDraftToken')
|
||||||
@@ -278,7 +280,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
|||||||
expect(transactions).toContain('业务订单号')
|
expect(transactions).toContain('业务订单号')
|
||||||
expect(transactions).toContain('入库单号')
|
expect(transactions).toContain('入库单号')
|
||||||
expect(transactions).toContain('出库单号')
|
expect(transactions).toContain('出库单号')
|
||||||
expect(transactions).toContain('不提供出库单跳转或库存变更操作')
|
expect(transactions).not.toContain('本页面仅用于查询库存流水')
|
||||||
expect(transactions).not.toContain('当前流水 DTO')
|
expect(transactions).not.toContain('当前流水 DTO')
|
||||||
expect(transactions).toContain("router.push({ name: 'overseas-inventory' })")
|
expect(transactions).toContain("router.push({ name: 'overseas-inventory' })")
|
||||||
expect(transactions).not.toContain('保存')
|
expect(transactions).not.toContain('保存')
|
||||||
|
|||||||
@@ -79,6 +79,6 @@ describe('overseas warehouse mounted real API pages', () => {
|
|||||||
expect(wrapper.text()).toContain('详情缺少客户范围')
|
expect(wrapper.text()).toContain('详情缺少客户范围')
|
||||||
expect(wrapper.text()).not.toContain('9007199254740995')
|
expect(wrapper.text()).not.toContain('9007199254740995')
|
||||||
})
|
})
|
||||||
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
|
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.find('.read-only-boundary').exists()).toBe(false); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
|
||||||
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.text()).toContain('货柜节点'); expect(wrapper.text()).toContain('已清关、可提柜'); expect(wrapper.text()).not.toContain('清关状态'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
|
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.text()).toContain('货柜节点'); expect(wrapper.text()).toContain('已清关、可提柜'); expect(wrapper.text()).not.toContain('清关状态'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="carrier-query" :class="{ 'admin-density': dense }" aria-label="货柜履约轨迹">
|
<section class="carrier-query" :class="{ 'admin-density': dense }" aria-label="货柜履约轨迹">
|
||||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" :class="{ 'density-action': dense }" data-tone="primary" size="small" type="primary" plain @click="openManualDialog"><Plus v-if="!dense" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
<header><div><h2>货柜履约轨迹</h2></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" :class="{ 'density-action': dense }" data-tone="primary" size="small" type="primary" plain @click="openManualDialog"><Plus v-if="!dense" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
|
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -286,5 +286,5 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.history-count{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px);white-space:nowrap}
|
.history-count{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px);white-space:nowrap}
|
||||||
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-5, 12px)}.header-actions{display:flex;align-items:center;gap:var(--admin-space-3, 8px)}.carrier-query h2{margin:0;font-size:var(--admin-font-section, 15px)}.carrier-query header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.loading-state{padding:var(--admin-space-2, 6px) 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-2, 6px) var(--admin-space-6, 16px);padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:var(--admin-space-2, 6px) 2px;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.empty-line{margin:0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.technical-details{border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts:not(.density-fields){display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-3, 8px) var(--admin-space-6, 16px);margin:4px 0 var(--admin-space-4, 10px)}.query-facts:not(.density-fields)>div{min-width:0}.query-facts:not(.density-fields) dt{color:var(--cc-muted);font-size:var(--admin-font-caption, 11px)}.query-facts:not(.density-fields) dd{margin:2px 0 0;font-size:var(--admin-font-caption, 12px)}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:var(--admin-space-3, 8px) 0 5px;font-size:var(--admin-font-caption, 12px)}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:var(--admin-space-3, 8px);padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-form{display:grid;gap:2px;margin-top:var(--admin-space-5, 12px)}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5, 12px)}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}.carrier-query.admin-density .fulfillment-timeline :is(time,span,small){overflow:visible;white-space:normal;overflow-wrap:anywhere}.carrier-query.admin-density .technical-history small{overflow-wrap:anywhere}
|
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-5, 12px)}.header-actions{display:flex;align-items:center;gap:var(--admin-space-3, 8px)}.carrier-query h2{margin:0;font-size:var(--admin-font-section, 15px)}.loading-state{padding:var(--admin-space-2, 6px) 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-2, 6px) var(--admin-space-6, 16px);padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:var(--admin-space-2, 6px) 2px;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.empty-line{margin:0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.technical-details{border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts:not(.density-fields){display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-3, 8px) var(--admin-space-6, 16px);margin:4px 0 var(--admin-space-4, 10px)}.query-facts:not(.density-fields)>div{min-width:0}.query-facts:not(.density-fields) dt{color:var(--cc-muted);font-size:var(--admin-font-caption, 11px)}.query-facts:not(.density-fields) dd{margin:2px 0 0;font-size:var(--admin-font-caption, 12px)}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:var(--admin-space-3, 8px) 0 5px;font-size:var(--admin-font-caption, 12px)}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:var(--admin-space-3, 8px);padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-form{display:grid;gap:2px;margin-top:var(--admin-space-5, 12px)}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5, 12px)}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}.carrier-query.admin-density .fulfillment-timeline :is(time,span,small){overflow:visible;white-space:normal;overflow-wrap:anywhere}.carrier-query.admin-density .technical-history small{overflow-wrap:anywhere}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ onMounted(() => void load())
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<el-dialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增船期' : '编辑船期'" width="760px" destroy-on-close><el-form v-loading="formOptionsLoading" label-position="top" class="sailing-form"><el-row :gutter="16"><el-col :span="12"><el-form-item label="收货仓" required><el-select v-model="form.warehouseId" filterable style="width:100%" placeholder="请选择收货仓" @change="onFormWarehouseChanged"><el-option v-if="form.warehouseId && !warehouseOptions.some((item) => String(item.id) === form.warehouseId)" :value="form.warehouseId" :label="form.warehouseName || '原收货仓(已不可选)'" /><el-option v-for="warehouse in warehouseOptions" :key="String(warehouse.id)" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="渠道" required><el-select v-model="form.channelCode" filterable clearable style="width:100%" placeholder="请选择已有渠道" @change="onChannelChanged"><el-option v-for="channel in channels.filter((item) => item.status === 'ACTIVE' || item.channelCode === form.channelCode)" :key="channel.channelCode" :label="`${channel.channelName}(${channel.channelCode})`" :value="channel.channelCode" /></el-select><span class="form-hint">选择已有渠道,自动带入排船默认资料。</span></el-form-item></el-col><el-col :span="12"><el-form-item label="船名 / 航次"><div class="form-readonly-value">{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}</div><span class="form-hint">未确认船名、航次时可先排船,显示为待订舱确认。</span></el-form-item></el-col><el-col :span="24"><el-collapse v-model="routeExpanded"><el-collapse-item name="route" :title="`航线资料:${form.departurePortCode || '待补起运港'} → ${form.destinationPortCode || '待补目的港'}`"><el-row :gutter="16"><el-col :span="12"><el-form-item label="起运港" required><el-input v-model="form.departurePortCode" placeholder="价格表未配置时补充,如 CNNGB" /></el-form-item></el-col><el-col :span="12"><el-form-item label="目的港" required><el-input v-model="form.destinationPortCode" placeholder="渠道未配置时补充,如 USLGB" /></el-form-item></el-col></el-row></el-collapse-item></el-collapse></el-col><el-col :span="12"><el-form-item label="预计开船" required><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" @change="onEtdChanged" /></el-form-item></el-col><el-col :span="12"><el-form-item label="收货截止规则" required><div class="cutoff-config"><el-radio-group v-model="cutoffMode" class="cutoff-mode-group" @change="onCutoffModeChanged"><el-radio-button label="rule">按开船前 N 天</el-radio-button><el-radio-button label="custom">自定义时间</el-radio-button></el-radio-group><div v-if="cutoffMode === 'rule'" class="cutoff-rule-panel"><div class="cutoff-rule-row"><span class="cutoff-control-label">提前天数</span><el-radio-group v-model="cutoffDays" class="cutoff-presets" @change="onCutoffRuleChanged"><el-radio-button :label="2">2 天</el-radio-button><el-radio-button :label="3">3 天</el-radio-button><el-radio-button :label="4">4 天</el-radio-button></el-radio-group><el-input-number v-model="cutoffDays" class="cutoff-days-input" :min="0" :max="30" controls-position="right" placeholder="请输入提前天数" @change="onCutoffRuleChanged" /></div><div class="cutoff-time-row"><span class="cutoff-control-label">截止时刻</span><el-time-picker v-model="cutoffLocalTime" value-format="HH:mm:ss" class="cutoff-time-picker" @change="onCutoffRuleChanged" /></div><div class="cutoff-calculated"><span>建议截止</span><strong>{{ dateTime(form.cutoffAt) }}</strong><small>按开船本地日期倒推自然日</small></div></div><el-date-picker v-else v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" @change="onCustomCutoffChanged" /></div></el-form-item></el-col></el-row><el-alert title="选择收货仓、渠道并设置开船和截止时间;航线资料优先使用渠道默认值,缺失时展开补充。" type="info" :closable="false" /></el-form><template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="formOptionsLoading" @click="save">保存</el-button></template></el-dialog>
|
<el-dialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增船期' : '编辑船期'" width="760px" destroy-on-close><el-form v-loading="formOptionsLoading" label-position="top" class="sailing-form"><el-row :gutter="16"><el-col :span="12"><el-form-item label="收货仓" required><el-select v-model="form.warehouseId" filterable style="width:100%" placeholder="请选择收货仓" @change="onFormWarehouseChanged"><el-option v-if="form.warehouseId && !warehouseOptions.some((item) => String(item.id) === form.warehouseId)" :value="form.warehouseId" :label="form.warehouseName || '原收货仓(已不可选)'" /><el-option v-for="warehouse in warehouseOptions" :key="String(warehouse.id)" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="渠道" required><el-select v-model="form.channelCode" filterable clearable style="width:100%" placeholder="请选择已有渠道" @change="onChannelChanged"><el-option v-for="channel in channels.filter((item) => item.status === 'ACTIVE' || item.channelCode === form.channelCode)" :key="channel.channelCode" :label="`${channel.channelName}(${channel.channelCode})`" :value="channel.channelCode" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="船名 / 航次"><div class="form-readonly-value">{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}</div><span class="form-hint">未确认船名、航次时可先排船,显示为待订舱确认。</span></el-form-item></el-col><el-col :span="24"><el-collapse v-model="routeExpanded"><el-collapse-item name="route" :title="`航线资料:${form.departurePortCode || '待补起运港'} → ${form.destinationPortCode || '待补目的港'}`"><el-row :gutter="16"><el-col :span="12"><el-form-item label="起运港" required><el-input v-model="form.departurePortCode" placeholder="价格表未配置时补充,如 CNNGB" /></el-form-item></el-col><el-col :span="12"><el-form-item label="目的港" required><el-input v-model="form.destinationPortCode" placeholder="渠道未配置时补充,如 USLGB" /></el-form-item></el-col></el-row></el-collapse-item></el-collapse></el-col><el-col :span="12"><el-form-item label="预计开船" required><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" @change="onEtdChanged" /></el-form-item></el-col><el-col :span="12"><el-form-item label="收货截止规则" required><div class="cutoff-config"><el-radio-group v-model="cutoffMode" class="cutoff-mode-group" @change="onCutoffModeChanged"><el-radio-button label="rule">按开船前 N 天</el-radio-button><el-radio-button label="custom">自定义时间</el-radio-button></el-radio-group><div v-if="cutoffMode === 'rule'" class="cutoff-rule-panel"><div class="cutoff-rule-row"><span class="cutoff-control-label">提前天数</span><el-radio-group v-model="cutoffDays" class="cutoff-presets" @change="onCutoffRuleChanged"><el-radio-button :label="2">2 天</el-radio-button><el-radio-button :label="3">3 天</el-radio-button><el-radio-button :label="4">4 天</el-radio-button></el-radio-group><el-input-number v-model="cutoffDays" class="cutoff-days-input" :min="0" :max="30" controls-position="right" placeholder="请输入提前天数" @change="onCutoffRuleChanged" /></div><div class="cutoff-time-row"><span class="cutoff-control-label">截止时刻</span><el-time-picker v-model="cutoffLocalTime" value-format="HH:mm:ss" class="cutoff-time-picker" @change="onCutoffRuleChanged" /></div><div class="cutoff-calculated"><span>建议截止</span><strong>{{ dateTime(form.cutoffAt) }}</strong><small>按开船本地日期倒推自然日</small></div></div><el-date-picker v-else v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" @change="onCustomCutoffChanged" /></div></el-form-item></el-col></el-row><el-alert title="选择收货仓、渠道并设置开船和截止时间;航线资料优先使用渠道默认值,缺失时展开补充。" type="info" :closable="false" /></el-form><template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="formOptionsLoading" @click="save">保存</el-button></template></el-dialog>
|
||||||
<el-dialog class="admin-density-dialog" v-model="detailOpen" title="货量明细(只读)" width="760px"><div v-loading="detailLoading"><template v-if="volumeDetail"><div class="detail-summary"><span>已收实测 <b>{{ decimal(volumeDetail.summary.actualReceivedCbm) }}</b></span><span>截止前参考 <b>{{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}</b></span><span>晚到 / 风险 <b>{{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}</b></span><span>预计参考 <b>{{ decimal(volumeDetail.summary.catchableForecastCbm) }}</b></span><span>待确认 <b>{{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}</b></span></div><el-alert title="本页面只展示现有审核收货和已放舱订舱数据,不会自动归班或改绑货物。预计量没有有效体积来源时显示 0,仅代表暂无可核验数据。" type="info" :closable="false" class="detail-readonly-note" /><el-table :data="volumeDetail.assignments" size="small" empty-text="暂无已明确归属本船的明细;上方为同仓、同渠道按截止窗口汇总的只读数据"><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="volumeCbm" label="体积" width="110"><template #default="{ row }">{{ decimal(row.volumeCbm) }}</template></el-table-column><el-table-column prop="status" label="来源状态" width="130" /><el-table-column prop="receivedAt" label="收货时间" min-width="150"><template #default="{ row }">{{ dateTime(row.receivedAt) }}</template></el-table-column></el-table></template></div></el-dialog>
|
<el-dialog class="admin-density-dialog" v-model="detailOpen" title="货量明细(只读)" width="760px"><div v-loading="detailLoading"><template v-if="volumeDetail"><div class="detail-summary"><span>已收实测 <b>{{ decimal(volumeDetail.summary.actualReceivedCbm) }}</b></span><span>截止前参考 <b>{{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}</b></span><span>晚到 / 风险 <b>{{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}</b></span><span>预计参考 <b>{{ decimal(volumeDetail.summary.catchableForecastCbm) }}</b></span><span>待确认 <b>{{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}</b></span></div><el-alert title="本页面只展示现有审核收货和已放舱订舱数据,不会自动归班或改绑货物。预计量没有有效体积来源时显示 0,仅代表暂无可核验数据。" type="info" :closable="false" class="detail-readonly-note" /><el-table :data="volumeDetail.assignments" size="small" empty-text="暂无已明确归属本船的明细;上方为同仓、同渠道按截止窗口汇总的只读数据"><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="volumeCbm" label="体积" width="110"><template #default="{ row }">{{ decimal(row.volumeCbm) }}</template></el-table-column><el-table-column prop="status" label="来源状态" width="130" /><el-table-column prop="receivedAt" label="收货时间" min-width="150"><template #default="{ row }">{{ dateTime(row.receivedAt) }}</template></el-table-column></el-table></template></div></el-dialog>
|
||||||
<el-dialog class="admin-density-dialog" v-model="historyOpen" title="船期变更历史" width="760px"><div v-loading="historyLoading"><el-table :data="histories" size="small" empty-text="暂无变更历史"><el-table-column prop="action" label="动作" width="110" /><el-table-column label="状态变化" width="170"><template #default="{ row }">{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}</template></el-table-column><el-table-column prop="reason" label="原因" min-width="220" /><el-table-column prop="operatorName" label="操作人" width="120" /><el-table-column prop="createdAt" label="时间" min-width="160"><template #default="{ row }">{{ dateTime(row.createdAt) }}</template></el-table-column></el-table></div></el-dialog><el-dialog class="admin-density-dialog" v-model="adviceOpen" title="订柜建议(只读初算)" width="720px"><div v-loading="adviceLoading"><template v-if="bookingAdvice"><div class="advice-highlight"><span>已收实测 <b>{{ decimal(bookingAdvice.actualReceivedCbm || 0) }}</b></span><span>截止前参考 <b>{{ decimal(bookingAdvice.catchableTotalCbm) }}</b></span><span>晚到 / 风险 <b>{{ decimal(bookingAdvice.lateReceivedCbm || 0) }}</b></span><span>已订柜 <b>{{ bookingAdvice.bookedContainerCount }} 柜</b></span><span>建议补柜 <b>{{ bookingAdvice.pendingContainerCount }} 柜</b></span></div><p class="advice-note">以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。</p><el-table :data="bookingAdvice.scenarios" size="small"><el-table-column prop="containerTypeName" label="柜型" /><el-table-column prop="capacityCbm" label="规划容量" width="120"><template #default="{ row }">{{ decimal(row.capacityCbm) }}</template></el-table-column><el-table-column prop="recommendedContainerCount" label="建议柜数" width="100" /><el-table-column prop="pendingContainerCount" label="待补柜数" width="100" /></el-table><div class="merge-reference"><div class="merge-reference-head"><strong>相邻船期合船参考</strong><span>只做展示,不自动合并</span></div><div class="merge-reference-current"><span>本船参考量 <b>{{ decimal(bookingAdvice.catchableTotalCbm) }}</b></span><span>规划容量 <b>{{ decimal(bookingAdvice.planningCapacityCbm || 0) }}</b></span><span>剩余容量 <b>{{ decimal(currentRemainingCapacity) }}</b></span></div><el-table :data="adjacentSailings" size="small" empty-text="当前月份没有可对比的相邻船期"><el-table-column label="相邻船期" min-width="190"><template #default="{ row }"><strong>{{ row.vesselName }}</strong><div class="merge-reference-sub">{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}</div></template></el-table-column><el-table-column label="截止前参考" width="110"><template #default="{ row }">{{ decimal(referenceVolume(row)) }}</template></el-table-column><el-table-column label="剩余容量" width="100"><template #default="{ row }">{{ decimal(remainingCapacity(row)) }}</template></el-table-column><el-table-column label="建议" width="125"><template #default="{ row }"><el-tag size="small" :type="mergeAdvice(row) === '可人工评估合船' ? 'success' : 'info'">{{ mergeAdvice(row) }}</el-tag></template></el-table-column></el-table><p class="advice-note merge-reference-note">仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。</p></div></template></div></el-dialog>
|
<el-dialog class="admin-density-dialog" v-model="historyOpen" title="船期变更历史" width="760px"><div v-loading="historyLoading"><el-table :data="histories" size="small" empty-text="暂无变更历史"><el-table-column prop="action" label="动作" width="110" /><el-table-column label="状态变化" width="170"><template #default="{ row }">{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}</template></el-table-column><el-table-column prop="reason" label="原因" min-width="220" /><el-table-column prop="operatorName" label="操作人" width="120" /><el-table-column prop="createdAt" label="时间" min-width="160"><template #default="{ row }">{{ dateTime(row.createdAt) }}</template></el-table-column></el-table></div></el-dialog><el-dialog class="admin-density-dialog" v-model="adviceOpen" title="订柜建议(只读初算)" width="720px"><div v-loading="adviceLoading"><template v-if="bookingAdvice"><div class="advice-highlight"><span>已收实测 <b>{{ decimal(bookingAdvice.actualReceivedCbm || 0) }}</b></span><span>截止前参考 <b>{{ decimal(bookingAdvice.catchableTotalCbm) }}</b></span><span>晚到 / 风险 <b>{{ decimal(bookingAdvice.lateReceivedCbm || 0) }}</b></span><span>已订柜 <b>{{ bookingAdvice.bookedContainerCount }} 柜</b></span><span>建议补柜 <b>{{ bookingAdvice.pendingContainerCount }} 柜</b></span></div><p class="advice-note">以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。</p><el-table :data="bookingAdvice.scenarios" size="small"><el-table-column prop="containerTypeName" label="柜型" /><el-table-column prop="capacityCbm" label="规划容量" width="120"><template #default="{ row }">{{ decimal(row.capacityCbm) }}</template></el-table-column><el-table-column prop="recommendedContainerCount" label="建议柜数" width="100" /><el-table-column prop="pendingContainerCount" label="待补柜数" width="100" /></el-table><div class="merge-reference"><div class="merge-reference-head"><strong>相邻船期合船参考</strong><span>只做展示,不自动合并</span></div><div class="merge-reference-current"><span>本船参考量 <b>{{ decimal(bookingAdvice.catchableTotalCbm) }}</b></span><span>规划容量 <b>{{ decimal(bookingAdvice.planningCapacityCbm || 0) }}</b></span><span>剩余容量 <b>{{ decimal(currentRemainingCapacity) }}</b></span></div><el-table :data="adjacentSailings" size="small" empty-text="当前月份没有可对比的相邻船期"><el-table-column label="相邻船期" min-width="190"><template #default="{ row }"><strong>{{ row.vesselName }}</strong><div class="merge-reference-sub">{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}</div></template></el-table-column><el-table-column label="截止前参考" width="110"><template #default="{ row }">{{ decimal(referenceVolume(row)) }}</template></el-table-column><el-table-column label="剩余容量" width="100"><template #default="{ row }">{{ decimal(remainingCapacity(row)) }}</template></el-table-column><el-table-column label="建议" width="125"><template #default="{ row }"><el-tag size="small" :type="mergeAdvice(row) === '可人工评估合船' ? 'success' : 'info'">{{ mergeAdvice(row) }}</el-tag></template></el-table-column></el-table><p class="advice-note merge-reference-note">仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。</p></div></template></div></el-dialog>
|
||||||
<el-dialog class="admin-density-dialog" v-model="managementOpen" title="渠道与周模板管理" width="1080px"><div v-loading="managementLoading"><el-alert v-if="managementError" :title="managementError.message" type="error" :closable="false" class="management-error"><template #default><ApiErrorMeta :details="managementError" /></template></el-alert><el-tabs v-model="managementTab"><el-tab-pane label="渠道" name="channels"><div class="management-toolbar"><span>渠道只读展示当前生效的通用船期价格表;周模板按收货仓筛选可用渠道。</span></div><el-table :data="channels" size="small" empty-text="暂无渠道主数据"><el-table-column prop="channelCode" label="渠道编码" width="140" /><el-table-column prop="channelName" label="渠道名称" min-width="160" /><el-table-column prop="sourceType" label="来源" width="120" /><el-table-column prop="defaultCutoffDays" label="默认提前天数" width="130" /><el-table-column prop="status" label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template></el-table-column></el-table></el-tab-pane><el-tab-pane label="每周模板" name="templates"><div class="management-toolbar"><span>设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。</span><el-button v-if="canManageTemplates" type="primary" size="small" @click="openTemplateCreate"><Plus :size="14" />新增周模板</el-button></div><el-table :data="templates" size="small" empty-text="暂无周模板"><el-table-column prop="templateNo" label="模板编号" width="140" /><el-table-column prop="channelName" label="渠道" min-width="130" /><el-table-column prop="warehouseName" label="收货仓" min-width="140" /><el-table-column label="周几" width="140"><template #default="{ row }">{{ formatWeekdays(row.weekdays) }}</template></el-table-column><el-table-column label="开船 / 截止" width="150"><template #default="{ row }">{{ row.departureTime }} / {{ row.cutoffDays }}天</template></el-table-column><el-table-column prop="status" label="状态" width="100" /><el-table-column v-if="canManageTemplates" label="操作" width="220" fixed="right"><template #default="{ row }"><el-button size="small" link type="primary" @click="openTemplateEdit(row)">编辑</el-button><el-button size="small" link type="primary" :loading="templatePreviewLoading" @click="previewTemplate(row)">预览生成</el-button><el-button size="small" link type="success" :loading="templateGenerateLoading" @click="generateTemplate(row)">设置并生成</el-button></template></el-table-column></el-table></el-tab-pane></el-tabs></div></el-dialog>
|
<el-dialog class="admin-density-dialog" v-model="managementOpen" title="渠道与周模板管理" width="1080px"><div v-loading="managementLoading"><el-alert v-if="managementError" :title="managementError.message" type="error" :closable="false" class="management-error"><template #default><ApiErrorMeta :details="managementError" /></template></el-alert><el-tabs v-model="managementTab"><el-tab-pane label="渠道" name="channels"><div class="management-toolbar"><span>渠道只读展示当前生效的通用船期价格表;周模板按收货仓筛选可用渠道。</span></div><el-table :data="channels" size="small" empty-text="暂无渠道主数据"><el-table-column prop="channelCode" label="渠道编码" width="140" /><el-table-column prop="channelName" label="渠道名称" min-width="160" /><el-table-column prop="sourceType" label="来源" width="120" /><el-table-column prop="defaultCutoffDays" label="默认提前天数" width="130" /><el-table-column prop="status" label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template></el-table-column></el-table></el-tab-pane><el-tab-pane label="每周模板" name="templates"><div class="management-toolbar"><span>设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。</span><el-button v-if="canManageTemplates" type="primary" size="small" @click="openTemplateCreate"><Plus :size="14" />新增周模板</el-button></div><el-table :data="templates" size="small" empty-text="暂无周模板"><el-table-column prop="templateNo" label="模板编号" width="140" /><el-table-column prop="channelName" label="渠道" min-width="130" /><el-table-column prop="warehouseName" label="收货仓" min-width="140" /><el-table-column label="周几" width="140"><template #default="{ row }">{{ formatWeekdays(row.weekdays) }}</template></el-table-column><el-table-column label="开船 / 截止" width="150"><template #default="{ row }">{{ row.departureTime }} / {{ row.cutoffDays }}天</template></el-table-column><el-table-column prop="status" label="状态" width="100" /><el-table-column v-if="canManageTemplates" label="操作" width="220" fixed="right"><template #default="{ row }"><el-button size="small" link type="primary" @click="openTemplateEdit(row)">编辑</el-button><el-button size="small" link type="primary" :loading="templatePreviewLoading" @click="previewTemplate(row)">预览生成</el-button><el-button size="small" link type="success" :loading="templateGenerateLoading" @click="generateTemplate(row)">设置并生成</el-button></template></el-table-column></el-table></el-tab-pane></el-tabs></div></el-dialog>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
||||||
import AnalyticsChart from './AnalyticsChart.vue'
|
import AnalyticsChart from './AnalyticsChart.vue'
|
||||||
@@ -11,11 +12,10 @@ const hasData = computed(() => props.data.points.some(point => point.dataStatus
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section aria-label="SKU每日出库趋势">
|
<section aria-label="SKU每日出库趋势">
|
||||||
<h3>{{ data.summary.sku }} · 每日出库趋势</h3>
|
<h3>{{ data.summary.sku }} · 每日出库趋势 <InlineHelp title="出库趋势图例说明"><p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p></InlineHelp></h3>
|
||||||
<p>所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
|
<p>所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
|
||||||
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
|
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
|
||||||
<p v-if="!hasData">所选范围尚无可靠数据,未知日期不作为零销量。</p>
|
<p v-if="!hasData">所选范围尚无可靠数据,未知日期不作为零销量。</p>
|
||||||
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p>
|
|
||||||
<AnalyticsChart :option="option" class="sku-trend" aria-label="SKU每日出库趋势" />
|
<AnalyticsChart :option="option" class="sku-trend" aria-label="SKU每日出库趋势" />
|
||||||
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
|
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
|
||||||
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
|
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
|
||||||
|
|||||||
@@ -2643,7 +2643,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></div>
|
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></div>
|
||||||
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
|
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
|
||||||
<div class="weight-evidence__heading">
|
<div class="weight-evidence__heading">
|
||||||
<div><h4>称重图片与确认重量</h4><p>称重图片独立展示;重量值直接采用服务端确认结果。</p></div>
|
<div><h4>称重图片与确认重量</h4></div>
|
||||||
<dl><dt>实际总毛重</dt><dd>{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg</dd></dl>
|
<dl><dt>实际总毛重</dt><dd>{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg</dd></dl>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="measurementDetail?.weightResults.length" class="weight-evidence-grid">
|
<div v-if="measurementDetail?.weightResults.length" class="weight-evidence-grid">
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import InlineHelp from './InlineHelp.vue'
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
function help() {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
return mount(InlineHelp, {
|
||||||
|
props: { title: '计费说明' },
|
||||||
|
slots: { default: '<p>实重与体积重取大计费。</p><p>费用确认后才发布。</p>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [ElementPlus] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function visiblePopup() {
|
||||||
|
const popup = document.querySelector('.inline-help__content')?.closest<HTMLElement>('[role="tooltip"]')
|
||||||
|
return popup && popup.getAttribute('aria-hidden') !== 'true' && popup.style.display !== 'none' ? popup : null
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InlineHelp', () => {
|
||||||
|
it('renders an accessible icon without an expandable row or inline rule text', () => {
|
||||||
|
const wrapper = help()
|
||||||
|
expect(wrapper.get('button').attributes('aria-label')).toBe('查看计费说明')
|
||||||
|
expect(wrapper.find('svg').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('details').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('p').exists()).toBe(false)
|
||||||
|
expect(visiblePopup()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows rules on hover in a floating tooltip and hides them after leaving', async () => {
|
||||||
|
const wrapper = help()
|
||||||
|
await wrapper.get('button').trigger('mouseenter')
|
||||||
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
await flushPromises()
|
||||||
|
expect(visiblePopup()?.textContent).toContain('实重与体积重取大计费。')
|
||||||
|
expect(visiblePopup()?.textContent).toContain('费用确认后才发布。')
|
||||||
|
expect(wrapper.find('p').exists()).toBe(false)
|
||||||
|
await wrapper.get('button').trigger('mouseleave')
|
||||||
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
await flushPromises()
|
||||||
|
expect(visiblePopup()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('also exposes rules to keyboard focus without expanding the page', async () => {
|
||||||
|
const wrapper = help()
|
||||||
|
await wrapper.get('button').trigger('focus')
|
||||||
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
await flushPromises()
|
||||||
|
expect(visiblePopup()?.textContent).toContain('费用确认后才发布。')
|
||||||
|
expect(wrapper.find('details').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Info } from '@lucide/vue'
|
||||||
|
withDefaults(defineProps<{ title?: string }>(), { title: '操作说明' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-tooltip :trigger="['hover', 'focus']" placement="top" effect="light" :show-after="150" :hide-after="100" :enterable="true">
|
||||||
|
<template #content><div class="inline-help__content"><strong>{{ title }}</strong><slot /></div></template>
|
||||||
|
<button type="button" class="inline-help__trigger" :aria-label="`查看${title}`"><Info :size="14" aria-hidden="true" /></button>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inline-help__trigger { display: inline-flex; width: 24px; height: 24px; flex: 0 0 24px; align-items: center; justify-content: center; margin: 0; padding: 0; border: 0; background: transparent; color: var(--cc-muted, #667085); cursor: help; vertical-align: middle; }
|
||||||
|
.inline-help__trigger:hover, .inline-help__trigger:focus-visible { color: var(--cc-primary, #1677ff); }
|
||||||
|
.inline-help__trigger:focus-visible { outline: 2px solid var(--cc-primary, #1677ff); outline-offset: 2px; }
|
||||||
|
.inline-help__content { max-width: 320px; color: var(--cc-text, #172033); font-size: var(--admin-font-caption, 12px); font-weight: 400; line-height: 1.6; white-space: normal; }
|
||||||
|
.inline-help__content > strong { display: block; margin-bottom: var(--admin-space-1, 4px); }
|
||||||
|
.inline-help__content :deep(p) { margin: 0; }
|
||||||
|
.inline-help__content :deep(p + p) { margin-top: var(--admin-space-1, 4px); }
|
||||||
|
</style>
|
||||||
@@ -213,3 +213,47 @@
|
|||||||
:is(.admin-density, .admin-density-dialog) .density-fields > div { display: grid; grid-template-columns: var(--admin-field-label-width, var(--admin-label-width)) minmax(0, 1fr); min-width: 0; min-height: var(--admin-row-height); gap: 0; padding: 0; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); border-radius: 0; background: var(--admin-surface); }
|
:is(.admin-density, .admin-density-dialog) .density-fields > div { display: grid; grid-template-columns: var(--admin-field-label-width, var(--admin-label-width)) minmax(0, 1fr); min-width: 0; min-height: var(--admin-row-height); gap: 0; padding: 0; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); border-radius: 0; background: var(--admin-surface); }
|
||||||
:is(.admin-density, .admin-density-dialog) .density-fields dt { display: flex; align-items: center; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: var(--cc-muted); background: var(--admin-label-bg); font-size: var(--admin-font-caption); line-height: 20px; }
|
:is(.admin-density, .admin-density-dialog) .density-fields dt { display: flex; align-items: center; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: var(--cc-muted); background: var(--admin-label-bg); font-size: var(--admin-font-caption); line-height: 20px; }
|
||||||
:is(.admin-density, .admin-density-dialog) .density-fields dd { align-self: center; min-width: 0; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: #25324a; font-size: var(--admin-font-body); font-weight: 400; line-height: 20px; overflow-wrap: anywhere; white-space: normal; }
|
:is(.admin-density, .admin-density-dialog) .density-fields dd { align-self: center; min-width: 0; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: #25324a; font-size: var(--admin-font-body); font-weight: 400; line-height: 20px; overflow-wrap: anywhere; white-space: normal; }
|
||||||
|
|
||||||
|
/* Dispatch dialog sections: explicit opt-in, scoped to dialogs. */
|
||||||
|
.admin-density-dialog .density-dialog-section {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 0 var(--admin-space-5);
|
||||||
|
padding: var(--admin-space-4) var(--admin-space-5);
|
||||||
|
border: 1px solid var(--admin-table-border);
|
||||||
|
border-radius: var(--admin-dialog-section-radius);
|
||||||
|
background: var(--admin-surface);
|
||||||
|
}
|
||||||
|
.admin-density-dialog .density-dialog-section:last-child { margin-bottom: 0; }
|
||||||
|
.admin-density-dialog .density-dialog-sections { display: grid; gap: var(--admin-space-5); }
|
||||||
|
.admin-density-dialog .density-dialog-sections > .density-dialog-section { margin: 0; }
|
||||||
|
.admin-density-dialog .density-dialog-section[data-tone="summary"] {
|
||||||
|
border-color: var(--admin-dialog-summary-border);
|
||||||
|
background: var(--admin-dialog-summary-bg);
|
||||||
|
}
|
||||||
|
.admin-density-dialog .density-dialog-section[data-tone="source"] {
|
||||||
|
border-color: var(--admin-dialog-source-border);
|
||||||
|
background: var(--admin-dialog-source-bg);
|
||||||
|
}
|
||||||
|
.admin-density-dialog .density-dialog-section[data-tone="work"] {
|
||||||
|
border-color: var(--admin-dialog-work-border);
|
||||||
|
background: var(--admin-dialog-work-bg);
|
||||||
|
}
|
||||||
|
.admin-density-dialog .density-dialog-section > .density-dialog-section__title {
|
||||||
|
display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-3);
|
||||||
|
margin: 0 0 var(--admin-space-4); color: var(--cc-text); font-size: var(--admin-font-section); font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-density-dialog .density-dialog-section__title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
|
||||||
|
.admin-density-dialog .density-dialog-note { margin: 0 0 var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||||
|
.admin-density-dialog .density-dialog-section > .el-form > .el-form-item:last-child { margin-bottom: 0; }
|
||||||
|
.admin-density-dialog .density-dialog-section .el-table { --el-table-tr-bg-color: var(--admin-surface); }
|
||||||
|
|
||||||
|
/* Dispatch dialog continuous table editors. */
|
||||||
|
.admin-density-dialog .density-table--editable :is(.el-input, .el-textarea) {
|
||||||
|
--el-input-border-color: transparent;
|
||||||
|
--el-input-hover-border-color: var(--admin-table-border);
|
||||||
|
--el-input-bg-color: transparent;
|
||||||
|
}
|
||||||
|
.admin-density-dialog .density-table--editable .el-input__inner { text-align: left; }
|
||||||
|
.admin-density-dialog .density-table--editable .el-form-item { margin-bottom: 0; }
|
||||||
|
.admin-density-dialog .density-table--editable .el-form-item.is-error { margin-bottom: var(--admin-space-5); }
|
||||||
|
.admin-density-dialog .density-table--editable :is(.el-input-number, .el-segmented, .el-date-editor) { width: 100%; min-width: 0; }
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
--admin-table-border: #e5e7eb;
|
--admin-table-border: #e5e7eb;
|
||||||
--admin-row-hover: #f3f7fb;
|
--admin-row-hover: #f3f7fb;
|
||||||
--admin-row-stripe: #fcfcfd;
|
--admin-row-stripe: #fcfcfd;
|
||||||
|
--admin-dialog-section-radius: 8px;
|
||||||
|
--admin-dialog-summary-bg: #f4fbf8;
|
||||||
|
--admin-dialog-summary-border: #cce8df;
|
||||||
|
--admin-dialog-source-bg: #f7f9ff;
|
||||||
|
--admin-dialog-source-border: #d9e2fa;
|
||||||
|
--admin-dialog-work-bg: #fffbf4;
|
||||||
|
--admin-dialog-work-border: #eadfc9;
|
||||||
--admin-action-neutral: #1777ff;
|
--admin-action-neutral: #1777ff;
|
||||||
--admin-action-neutral-border: #dfe3ee;
|
--admin-action-neutral-border: #dfe3ee;
|
||||||
--admin-action-warning: #c76500;
|
--admin-action-warning: #c76500;
|
||||||
|
|||||||
Reference in New Issue
Block a user