Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24ebfcf2e1 | ||
|
|
aac878c3d4 | ||
|
|
d49210575b | ||
|
|
30a0d7ce68 |
@@ -11,3 +11,5 @@
|
||||
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 待人工截图确认 |
|
||||
|
||||
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
|
||||
|
||||
2026-10-22 用户明确授权 AI 操作 TEST 页面后,已实际核对新增页:1920×1080 下初始仅第 1 步高亮、后续待处理;白底编辑控件、蓝色聚焦边框与真实灰底禁用状态可区分;船公司下拉及仓库—渠道联动可用。截图保存在本机 `C:/Users/BY-OY/.codex/booking-ui-acceptance-20261022/booking-new-progress.png`。保存未通过:独立测试请求返回 S0500,服务日志与只读结构核查确认 TEST 缺少订舱货物/关系方字段且冻结触发器仍为旧版。完整保存、放舱、补实和两类 PDF 预览仍待数据库结构同步后验收,不将本次局部页面核对记为完整流程通过。环境事实以部署主契约“订舱页面 TEST 验收阻断”为准。
|
||||
|
||||
@@ -341,9 +341,9 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
expect(declaredInput.attributes('disabled')).toBeDefined()
|
||||
expect(declaredInput.attributes('aria-disabled')).toBe('true')
|
||||
}
|
||||
vm.form.data.actualPackageCount = 480
|
||||
vm.form.data.actualGrossWeightKg = 14320.125
|
||||
vm.form.data.actualVolumeCbm = 65.321
|
||||
await wrapper.get('input[aria-label="实际件数"]').setValue('480')
|
||||
await wrapper.get('input[aria-label="实际毛重"]').setValue('14320.125')
|
||||
await wrapper.get('input[aria-label="实际体积"]').setValue('65.321')
|
||||
vm.actualReason = ' 实装后补录 '
|
||||
await flushPromises()
|
||||
expect(vm.printDisabledReason).toContain('请先保存')
|
||||
@@ -354,6 +354,29 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
expect(vm.actualReason).toBe('')
|
||||
expect(vm.hasUnsavedActualChanges).toBe(false)
|
||||
expect(vm.form.data.declaredPackageCount).toBe(500)
|
||||
expect((wrapper.get('input[aria-label="实际件数"]').element as HTMLInputElement).value).toBe('480')
|
||||
expect((wrapper.get('input[aria-label="实际毛重"]').element as HTMLInputElement).value).toBe('14320.125')
|
||||
expect((wrapper.get('input[aria-label="实际体积"]').element as HTMLInputElement).value).toBe('65.321')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the saved server values after input and blur without a change event', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 })
|
||||
const { vm, wrapper } = await mountView()
|
||||
for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) {
|
||||
const input = wrapper.get(`input[aria-label="${label}"]`)
|
||||
const element = input.element as HTMLInputElement
|
||||
element.value = value
|
||||
await input.trigger('input')
|
||||
await input.trigger('blur')
|
||||
}
|
||||
vm.actualReason = '补录'
|
||||
await vm.saveActualCargo()
|
||||
await flushPromises()
|
||||
for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) {
|
||||
expect((wrapper.get(`input[aria-label="${label}"]`).element as HTMLInputElement).value).toBe(value)
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -514,13 +514,13 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<h3 class="booking-group__title"><span>货物信息</span></h3>
|
||||
<el-form label-position="top" class="density-form booking-group__body">
|
||||
<div class="cargo-metrics" role="group" aria-label="货物预报与实际数量">
|
||||
<!-- 数字组件只在挂载时写入 aria-disabled;可编辑资格切换时重建,保持辅助标记与原生禁用状态一致。 -->
|
||||
<!-- 数字组件仅挂载时写入禁用标记,且可能保留输入缓存;资格切换及实值保存成功后按权威版本重建。 -->
|
||||
<div class="cargo-metric">
|
||||
<span class="cargo-metric__label">件数(委 / 实)</span>
|
||||
<div class="cargo-metric__inputs">
|
||||
<el-input-number :key="'declared-count-' + formWritable" v-model="form.data.declaredPackageCount" :disabled="!formWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="预报量" aria-label="预报件数" />
|
||||
<span class="cargo-metric__separator" aria-hidden="true">/</span>
|
||||
<el-input-number :key="'actual-count-' + cargoWritable" v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
|
||||
<el-input-number :key="'actual-count-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
|
||||
<span class="cargo-metric__unit">{{ form.data.packageUnit || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,7 +529,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<div class="cargo-metric__inputs">
|
||||
<el-input-number :key="'declared-weight-' + formWritable" v-model="form.data.declaredGrossWeightKg" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报毛重" />
|
||||
<span class="cargo-metric__separator" aria-hidden="true">/</span>
|
||||
<el-input-number :key="'actual-weight-' + cargoWritable" v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
|
||||
<el-input-number :key="'actual-weight-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
|
||||
<span class="cargo-metric__unit">KGS</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,7 +538,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<div class="cargo-metric__inputs">
|
||||
<el-input-number :key="'declared-volume-' + formWritable" v-model="form.data.declaredVolumeCbm" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报体积" />
|
||||
<span class="cargo-metric__separator" aria-hidden="true">/</span>
|
||||
<el-input-number :key="'actual-volume-' + cargoWritable" v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
|
||||
<el-input-number :key="'actual-volume-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
|
||||
<span class="cargo-metric__unit">CBM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,15 @@ beforeEach(() => {
|
||||
vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
|
||||
vi.mocked(http.delete).mockResolvedValue({ data: draft() })
|
||||
})
|
||||
it('reads and writes original dispatch appointment allocations with lossless versions', async () => {
|
||||
const value = { dispatchNo: 'PC/1', recordVersion: '9007199254740993', items: [], edit: { allowed: true, blockCode: null, blockReason: null } }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
|
||||
expect(await api.getDispatchAppointmentAllocations('PC/1')).toEqual(value)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations')
|
||||
const body = { recordVersion: 0, idempotencyKey: 'allocation-key', dispatchItemIds: ['11'], batchAllocations: [{ planPalletId: '21', batchKey: 'FBA_APPOINTMENT:9', customerId: '101', sourceKey: 'ORDER:1', destinationKey: 'FBA:ACY2', packageCount: 10, recordVersion: 0 }] }
|
||||
await api.saveDispatchAppointmentAllocations('PC/1', body)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations', body)
|
||||
})
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { AppointmentAllocations, AppointmentAllocationCommand } from '../dispatchContracts'
|
||||
import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
|
||||
@@ -17,6 +18,19 @@ export async function listDispatches(query: DispatchQuery) {
|
||||
}
|
||||
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
|
||||
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
|
||||
export async function getDispatchAppointmentAllocations(no: string) {
|
||||
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
|
||||
requireVersion(value.recordVersion)
|
||||
value.items.forEach(item => { requireBatchVersions(item.allocations); item.sources.forEach(source => source.options.forEach(option => requireVersion(option.recordVersion))) })
|
||||
return value
|
||||
}
|
||||
export const saveDispatchAppointmentAllocations = (no: string, body: AppointmentAllocationCommand) => {
|
||||
versioned(body); requireBatchVersions(body.batchAllocations)
|
||||
return postAppointmentAllocations(no, body)
|
||||
}
|
||||
async function postAppointmentAllocations(no: string, body: AppointmentAllocationCommand) {
|
||||
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/appointment-allocations`, body)))
|
||||
}
|
||||
export const getDispatchRoute = (no: string) => get<DispatchRoute>(`${path(no)}/route`)
|
||||
export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/route`, versioned(body)))) }
|
||||
const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import ElementPlus, { ElInputNumber } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { AppointmentAllocations } from '../dispatchContracts'
|
||||
import { command } from '../dispatchTestFixtures'
|
||||
|
||||
const access = vi.hoisted(() => ({ can: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.can }) }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatchAppointmentAllocations: vi.fn(), saveDispatchAppointmentAllocations: vi.fn() }))
|
||||
function facts(): AppointmentAllocations {
|
||||
return { dispatchNo: 'PC/1', recordVersion: '0', edit: { allowed: true, blockCode: null, blockReason: null }, items: ['11', '12'].map((id, index) => ({
|
||||
dispatchItemId: id, planPalletId: `${index + 21}`, customerId: '101', customerName: '客户甲', palletNo: `PALLET-${index + 1}`, allocations: [],
|
||||
sources: [{ sourceKey: 'ORDER:1', orderNo: 'WC-1', inboundCode: 'IN-1', destinationKey: 'FBA:ACY2', packageCount: 10, blockReasons: [],
|
||||
options: [{ batchKey: 'FBA_APPOINTMENT:9', batchNo: 'YY-9', recordVersion: '0', availablePackages: 20, confirmationNo: 'CONF-9', appointmentAt: '2026-10-26T10:00:00', appointmentEndAt: null, timezone: 'America/New_York' }] }],
|
||||
})) }
|
||||
}
|
||||
async function open() {
|
||||
const wrapper = mount(DispatchAppointmentAllocationsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.can = true
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(facts())
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockResolvedValue(command('EDIT'))
|
||||
})
|
||||
it('links both pallets to the confirmed appointment on the original dispatch with explicit version zero', async () => {
|
||||
const wrapper = await open()
|
||||
expect(wrapper.text()).toContain('WC-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('America/New_York')
|
||||
expect(wrapper.text()).not.toContain('ORDER:1')
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1)
|
||||
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: '0', dispatchItemIds: ['11', '12'],
|
||||
batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => {
|
||||
const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('本票剩余可排量不足')
|
||||
wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 5); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('须完整分配 10 箱')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('preserves edited quantities after rejection and retries with current versions and a new command key', async () => {
|
||||
const value = facts(); value.items = value.items.slice(0, 1)
|
||||
value.items[0]!.sources[0]!.options.push({ ...value.items[0]!.sources[0]!.options[0]!, batchKey: 'FBA_APPOINTMENT:10', batchNo: 'YY-10' })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); const inputs = wrapper.findAllComponents(ElInputNumber)
|
||||
inputs[0]!.vm.$emit('update:modelValue', 6); inputs[1]!.vm.$emit('update:modelValue', 4); await flushPromises()
|
||||
const latest = structuredClone(value); latest.recordVersion = '2'; latest.items[0]!.sources[0]!.options.forEach(option => { option.recordVersion = '3' })
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('版本已变化'))
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([6, 4])
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
const calls = vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls
|
||||
expect(calls).toHaveLength(2); expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
|
||||
expect(calls[1]![1]).toMatchObject({ recordVersion: '2', batchAllocations: [{ recordVersion: '3', packageCount: 6 }, { recordVersion: '3', packageCount: 4 }] })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains an unavailable former choice after a failed save until the user clears it', async () => {
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('预约已变化'))
|
||||
const latest = facts(); latest.items[0]!.sources[0]!.options = []
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已失效'); expect(wrapper.findAllComponents(ElInputNumber)[0]!.props('modelValue')).toBe(10)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it.each(['permission', 'gate', 'unconfirmed'] as const)('does not save when blocked by %s', async mode => {
|
||||
const value = facts()
|
||||
if (mode === 'permission') access.can = false
|
||||
if (mode === 'gate') value.edit = { allowed: false, blockCode: 'NOT_ELIGIBLE', blockReason: '本车已有真实扫码' }
|
||||
if (mode === 'unconfirmed') value.items[0]!.sources[0] = { ...value.items[0]!.sources[0]!, options: [], blockReasons: ['请先确认送仓时间'] }
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getDispatchAppointmentAllocations, saveDispatchAppointmentAllocations } from '../api/dispatches'
|
||||
import { DispatchInputError, requireVersion, type AppointmentAllocations, type AppointmentAllocationItem, type AppointmentAllocationSource, type AppointmentAllocationOption, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
interface Choice extends AppointmentAllocationOption { packages: number; unavailable: boolean }
|
||||
interface SourceRow { key: string; item: AppointmentAllocationItem; source: AppointmentAllocationSource; choices: Choice[] }
|
||||
const context = ref<AppointmentAllocations | null>(null), rows = ref<SourceRow[]>([])
|
||||
const loading = ref(false), saving = ref(false), factsReady = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let generation = 0
|
||||
const permitted = computed(() => access.can('platform:overseas-warehouse:dispatch:edit') && context.value?.edit.allowed === true)
|
||||
const allocationKey = (item: AppointmentAllocationItem, source: AppointmentAllocationSource, batch: string) => JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey, batch])
|
||||
const quotaKey = (row: SourceRow, batch: string) => JSON.stringify([batch, row.item.customerId, row.source.sourceKey, row.source.destinationKey])
|
||||
const validation = computed(() => {
|
||||
const used = new Map<string, number>()
|
||||
for (const row of rows.value) {
|
||||
if (row.source.blockReasons.length) return row.source.blockReasons[0]!
|
||||
if (!Number.isSafeInteger(row.source.packageCount) || (row.source.packageCount ?? 0) <= 0) return '本票箱数尚未核实,请刷新本车货物'
|
||||
let total = 0
|
||||
for (const choice of row.choices) {
|
||||
if (!Number.isSafeInteger(choice.packages) || choice.packages < 0) return '分配箱数须为非负整数'
|
||||
if (!choice.packages) continue
|
||||
if (choice.unavailable) return '原选择的预约已失效,请将对应箱数清零后重新选择'
|
||||
total += choice.packages
|
||||
const key = quotaKey(row, choice.batchKey), count = (used.get(key) || 0) + choice.packages
|
||||
if (count > choice.availablePackages) return `预约 ${choice.batchNo} 的本票剩余可排量不足,请调整分配箱数`
|
||||
used.set(key, count)
|
||||
}
|
||||
if (total !== row.source.packageCount) return `托盘 ${row.item.palletNo} 的本票货物须完整分配 ${row.source.packageCount} 箱`
|
||||
}
|
||||
return rows.value.length ? '' : '本车暂无可关联正式预约的FBA货物'
|
||||
})
|
||||
const canSave = computed(() => factsReady.value && permitted.value && !loading.value && !saving.value && !validation.value)
|
||||
async function load(preserve = false) {
|
||||
const current = ++generation, no = props.dispatchNo
|
||||
const previous = new Map(rows.value.flatMap(row => row.choices.map(choice => [allocationKey(row.item, row.source, choice.batchKey), choice] as const)))
|
||||
loading.value = true; factsReady.value = false
|
||||
if (!preserve) { error.value = null; context.value = null; rows.value = [] }
|
||||
try {
|
||||
const value = await getDispatchAppointmentAllocations(no)
|
||||
if (current !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('预约分配与当前排车单不一致,请重新打开')
|
||||
requireVersion(value.recordVersion)
|
||||
const next: SourceRow[] = []
|
||||
for (const item of value.items) for (const source of item.sources.filter(source => source.destinationKey.startsWith('FBA:'))) {
|
||||
const key = JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey])
|
||||
const choices: Choice[] = source.options.map(option => {
|
||||
const retained = item.allocations.find(a => a.batchKey === option.batchKey && a.sourceKey === source.sourceKey && a.destinationKey === source.destinationKey)
|
||||
const old = preserve ? previous.get(allocationKey(item, source, option.batchKey)) : undefined
|
||||
return { ...option, packages: old?.packages ?? retained?.packageCount ?? (!preserve && !item.allocations.length && source.options.length === 1 ? source.packageCount || 0 : 0), unavailable: false }
|
||||
})
|
||||
if (preserve) for (const oldRow of rows.value.filter(row => row.key === key)) for (const old of oldRow.choices)
|
||||
if (old.packages > 0 && !choices.some(choice => choice.batchKey === old.batchKey)) choices.push({ ...old, unavailable: true })
|
||||
next.push({ key, item, source, choices })
|
||||
}
|
||||
context.value = value; rows.value = next; factsReady.value = true
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本车正式预约读取失败,请刷新重试') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave.value || !context.value) return
|
||||
const current = generation, no = props.dispatchNo
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
const batchAllocations: DispatchBatchAllocation[] = rows.value.flatMap(row => row.choices.filter(choice => choice.packages > 0).map(choice => ({
|
||||
planPalletId: row.item.planPalletId, customerId: row.item.customerId, sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey,
|
||||
batchKey: choice.batchKey, recordVersion: requireVersion(choice.recordVersion), packageCount: choice.packages,
|
||||
})))
|
||||
await saveDispatchAppointmentAllocations(no, { recordVersion: requireVersion(context.value.recordVersion), idempotencyKey: crypto.randomUUID(),
|
||||
dispatchItemIds: [...new Set(rows.value.map(row => row.item.dispatchItemId))], batchAllocations })
|
||||
if (current !== generation) return
|
||||
ElMessage.success('正式预约已关联,请核对运输时间后重新提交审核')
|
||||
emit('saved'); open.value = false
|
||||
} catch (cause) {
|
||||
if (current === generation) { error.value = getApiErrorDetails(cause, '正式预约关联未完成'); await load(true) }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
watch([open, () => props.dispatchNo], ([value]) => { if (value) void load(); else { generation++; factsReady.value = false } }, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="`关联正式预约 · ${dispatchNo}`" width="1100px" fullscreenable
|
||||
confirm-text="保存关联" :loading="loading" :confirm-loading="saving" :confirm-disabled="!canSave" :close-on-press-escape="!saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="正式预约关联未完成" :message="error.message" :loading="loading || saving" retry-text="刷新当前预约" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="context && !permitted" :title="context.edit.blockReason || '当前无关联预约资格'" type="warning" :closable="false" show-icon />
|
||||
<p class="density-dialog-note">按本车每托、每票货物核对已确认的正式预约及箱数。保存后保留原排车,重新提交两级审核。</p>
|
||||
<div class="appointment-table"><table class="density-table density-table--editable"><thead><tr><th>客户/托盘</th><th>订单号/入仓码</th><th>目的仓</th><th>本票箱数</th><th>正式预约/当地送达时间</th><th>本票可排箱数</th><th>分配箱数</th></tr></thead><tbody>
|
||||
<template v-for="row in rows" :key="row.key">
|
||||
<tr v-for="choice in row.choices" :key="choice.batchKey"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td>{{ choice.batchNo || '—' }}<span v-if="choice.unavailable">(已失效)</span><br>{{ choice.confirmationNo || '—' }}<br>{{ choice.appointmentAt?.replace('T', ' ') }}<template v-if="choice.appointmentEndAt"> ~ {{ choice.appointmentEndAt.replace('T', ' ') }}</template><br>{{ choice.timezone }}</td><td>{{ choice.availablePackages }}<span v-if="!choice.unavailable">(同票共用)</span></td><td><el-input-number v-model="choice.packages" :min="0" :precision="0" :controls="false" :disabled="loading || saving || !permitted" :aria-label="`${row.item.palletNo} ${choice.batchNo} 分配箱数`" /></td></tr>
|
||||
<tr v-if="!row.choices.length"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td colspan="3">{{ row.source.blockReasons.join(';') || '暂无已确认的正式预约,请先在预约列表确认送仓时间' }}</td></tr>
|
||||
</template><tr v-if="!loading && !rows.length"><td colspan="7">本车暂无可关联正式预约的FBA货物</td></tr>
|
||||
</tbody></table></div>
|
||||
<el-alert v-if="factsReady && permitted && validation" :title="validation" type="warning" :closable="false" show-icon />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appointment-table { overflow-x: auto; }
|
||||
.appointment-table table { min-width: 960px; }
|
||||
.appointment-table :deep(.el-input-number) { width: 112px; }
|
||||
</style>
|
||||
@@ -52,6 +52,7 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`关联订单 (${detail.orderDetails.length})`" name="orders">
|
||||
<slot name="appointments" />
|
||||
<div class="maintenance-orders"><table class="density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody><tr v-for="(order, index) in detail.orderDetails" :key="`${order.sourceKey}-${order.orderNo}-${index}`"><td>{{ order.customerName || '—' }}</td><td>{{ order.orderNo || '—' }}</td><td>{{ order.inboundCode || '—' }}</td><td>{{ order.grossWeightKg ?? '—' }}</td><td>{{ order.palletCount ?? '—' }}</td><td>{{ order.destinationFbaWarehouse || '—' }}</td><td>{{ order.volumeCbm ?? '—' }}</td><td>{{ order.containerNo || '—' }}</td></tr><tr v-if="!detail.orderDetails.length"><td colspan="8">暂无关联订单</td></tr></tbody></table></div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
@@ -11,6 +11,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('./DispatchAppointmentAllocationsDialog.vue', () => ({ default: { name: 'DispatchAppointmentAllocationsDialog', props: ['modelValue', 'dispatchNo'], emits: ['saved', 'update:modelValue'], template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string; publishLooseboardDemand: boolean }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
@@ -19,6 +20,18 @@ async function open(action: DispatchAction, allowed = true) {
|
||||
return { wrapper, state: wrapper.vm as unknown as State }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('opens appointment linking from vehicle maintenance and refreshes the original version without losing unsaved inputs', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
state.form.remark = '尚未保存的车辆备注'
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '关联正式预约')
|
||||
expect(button).toBeDefined(); await button!.trigger('click'); await flushPromises()
|
||||
const child = wrapper.findComponent({ name: 'DispatchAppointmentAllocationsDialog' })
|
||||
expect(child.props('dispatchNo')).toBe('PC/1')
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
|
||||
child.vm.$emit('saved'); await flushPromises()
|
||||
expect(state.detail.recordVersion).toBe(2); expect(state.form.remark).toBe('尚未保存的车辆备注')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
||||
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchActi
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
|
||||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
@@ -32,6 +33,9 @@ const currentOperation = computed(() => pending.value ? pending.value.operation
|
||||
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const factsStale = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const canLinkAppointments = computed(() => vehicleEditor.value && detail.value?.orderDetails.some(row => Boolean(row.destinationFbaWarehouse)) && canDispatchAction(detail.value?.actions, 'EDIT', access.can))
|
||||
async function appointmentsSaved() { error.value = null; await refreshFacts(); emit('saved') }
|
||||
const preview = ref<LoadingPreview | null>(null)
|
||||
const loading = computed(() => working.value)
|
||||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||||
@@ -148,6 +152,7 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
||||
await submit()
|
||||
}
|
||||
async function submit(submitReviewAfterSave = false) {
|
||||
if (appointmentsOpen.value) return
|
||||
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
@@ -217,6 +222,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||
<div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div>
|
||||
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<section v-if="currentOperation === 'ROUTE' && route" 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>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
|
||||
@@ -231,6 +237,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<template v-else-if="vehicleEditor">
|
||||
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<template #appointments><div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div></template>
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
@@ -273,6 +280,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<DispatchAppointmentAllocationsDialog v-if="appointmentsOpen" v-model="appointmentsOpen" :dispatch-no="currentNo" @saved="appointmentsSaved" />
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export interface AppointmentAllocationOption { batchKey: string; batchNo: string; recordVersion: Version; availablePackages: number; confirmationNo: string | null; appointmentAt: string; appointmentEndAt: string | null; timezone: string }
|
||||
export interface AppointmentAllocationSource { sourceKey: string; orderNo: string | null; inboundCode: string | null; destinationKey: string; packageCount: number | null; options: AppointmentAllocationOption[]; blockReasons: string[] }
|
||||
export interface AppointmentAllocationItem { dispatchItemId: string; planPalletId: string; customerId: string; customerName: string | null; palletNo: string; sources: AppointmentAllocationSource[]; allocations: DispatchBatchAllocation[] }
|
||||
export interface AppointmentAllocations { dispatchNo: string; recordVersion: Version; items: AppointmentAllocationItem[]; edit: Gate }
|
||||
export interface AppointmentAllocationCommand extends VersionCommand { dispatchItemIds: string[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export type DispatchEvidenceKind = 'EXCEPTION' | 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
|
||||
|
||||
Reference in New Issue
Block a user