diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md index 190e3ea8..8589f772 100644 --- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -1,12 +1,18 @@ # 预约工作日历逐操作矩阵 +## 2026-10-21 排车候选分页 + +| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 | +|---|---|---|---| +| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 | + ## 2026-10-20 日期时间组件统一(当前确认) 依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。 | 页面/组件 | 操作与承载 | 本次调整 | 验收 | |---|---|---|---| -| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 自动检查通过;待人工1920×1080、100%截图确认 | +| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 真实浏览器检查通过:日期面板、选值、空值禁用、全屏保值、未保存保护及放弃返回;未提交业务保存 | | WorkReceivingPanel | 原业务深链中的客户计划、后送与跟进,原表单 | 5处日期/日期时间输入;日期与秒精度的当地时间分别保存;调整开始/结束时段后仍清除原时差依据 | 自动检查通过;待人工截图确认 | | WorkCommandForm | 跟进、收货要求,原表单 | 3处日期、2处时间输入;跨日结束日期显式保留;不推断次日、不转换地点时区 | 自动检查通过;待人工截图确认 | | AppointmentBatchForm | 原分批/合并表单 | 2处可空目标日期,保留跨批分配、版本、幂等与原写权限 | 自动检查通过;待人工截图确认 | @@ -15,6 +21,33 @@ 自动验证:受影响7个测试文件88项通过;类型检查与生产构建通过。全量回归执行464个文件4203项,4201项通过,两项旧公共样式断言(标签宽度、追加样式类)更新后,该样式文件65项全部复验通过。后台业务模板原生日期/时间控件残留检查通过。本轮未部署TEST,未进行真实HTTP或人工页面截图验收。 +补充浏览器验收:用户明确要求打开页面后,使用已发布TEST页面,页面报告1920×1080、DPR1、visual scale1。TSTU5446539的默认计划、Element日期面板、快捷选值、全屏保值、清空禁用、未保存保护与放弃返回通过;原计划仍2026-10-01,未点击保存。MSCU7264017现已有入库计划,按冻结时间显示且无调整入口。本轮仅覆盖日历与调整到仓弹框,其余三表单仍待逐页操作;输入09:30的最终确认未单独完成,不计通过。证据见 `.logs/appointment-element-release-20261020/ui/result.json` 及同目录截图。 + +## 2026-10-20 蓝白工作台续批(写权已协调) + +本次续批修改文件(相对本模块): + +- `AppointmentBatchForm.vue` +- `AppointmentCalendarPanel.vue` +- `AppointmentCalendarView.vue` +- `AppointmentCreatePanel.vue` +- `ReceivingScope.vue` +- `SourceAppointmentPanel.vue` +- `TripAppointmentPanel.vue` +- `WorkDetailPanel.vue` +- `WorkReceivingPanel.vue` + +总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。 + +| 实际入口 | 操作/承载与冻结结论 | 验收 | +|---|---|---| +| 预约日历主页面及原业务深链的实际表单、工作详情;保留四类、月周日期与原弹框承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 | + + +续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。 + +当前无正式入口引用的11个旧面板保留原样:AppointmentCancellationDialog、AppointmentCancellationHistory、AppointmentCancellationRecords、AppointmentCargoPanel、AppointmentCargoTabs、AppointmentContainerPanel、AppointmentExternalCandidates、AppointmentFollowupDialog、AppointmentImpactDialog、AppointmentInboundCodes、WorkCalendarPanel。不是新增或恢复入口;TripAppointmentPanel仍由正式车次详情引用,已纳入本次续批。 + ## 2026-10-20 排车入口收敛 用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。 @@ -236,3 +269,5 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格 | 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 | 视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。 + +2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。 diff --git a/src/modules/appointment-calendar/WorkDispatchForm.test.ts b/src/modules/appointment-calendar/WorkDispatchForm.test.ts index 847c5f69..214f242d 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.test.ts +++ b/src/modules/appointment-calendar/WorkDispatchForm.test.ts @@ -48,7 +48,7 @@ beforeEach(() => { natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' } vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key)) vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] })) - vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' })) + vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' })) vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft)) vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) }) vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) }) @@ -58,7 +58,7 @@ beforeEach(() => { }) it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => { const actual = await vi.importActual('../overseas-warehouse/api/dispatches') - const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } }))) + const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { total: 1, page: 1, size: 20, pages: 1, candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } }))) vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates) const wrapper = render() try { @@ -98,7 +98,7 @@ it('uses a server-owned draft, explicit zero versions, native source hashes and }) it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => { const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] }) - const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true]) + const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true]) await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount() }) @@ -112,12 +112,12 @@ it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5') wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value))) vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] }) - const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false]) + const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false]) expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount() }) -it('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => { - vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount() - natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount() +it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => { + vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount() + natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount() vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value }) const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount() }) @@ -160,3 +160,38 @@ it('shows batch-level blockers even without rows and refuses duplicate physical const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount() const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount() }) + +it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => { + const first = row(), second = row('9007199254740994', 'customer-b') + natives = [candidate(first), candidate(second)] + vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [first, second], blockReasons: [] }) + vi.mocked(listDispatchCandidates).mockImplementation(async query => ({ total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [clone(natives[query.page === 2 ? 1 : 0]!)], complete: false, snapshotAt: '' })) + const wrapper = render() + try { + await flushPromises(); expect(wrapper.findAll('input[type=checkbox]')).toHaveLength(1) + await wrapper.get('input[type=checkbox]').setValue(true) + const state = wrapper.vm as unknown as { changeCandidatePage(page: number): void } + state.changeCandidatePage(2); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true) + expect(listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20, warehouseTab: 'ALL', warehouseId: 'origin-1' })) + expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1) + expect(listDispatchCandidates).toHaveBeenCalledTimes(2) + await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() + expect(convertDispatchDraft).toHaveBeenCalledTimes(1) + expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations]) + } finally { wrapper.unmount() } +}) +it('rejects a selected batch source whose group version changed on another candidate page', async () => { + vi.mocked(listDispatchCandidates).mockImplementation(async query => { + const value = clone(natives[0]!) + if (query.page === 2) value.selectionGroup.groupVersion = 'new-group-version' + return { total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [value], complete: false, snapshotAt: '' } + }) + const wrapper = render() + try { + await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true) + ;(wrapper.vm as unknown as { changeCandidatePage(page: number): void }).changeCandidatePage(2) + await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('选货组组成已变化') + expect(createDispatchDraft).not.toHaveBeenCalled() + } finally { wrapper.unmount() } +}) diff --git a/src/modules/appointment-calendar/WorkDispatchForm.vue b/src/modules/appointment-calendar/WorkDispatchForm.vue index 2f23dfb9..06723b6f 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.vue +++ b/src/modules/appointment-calendar/WorkDispatchForm.vue @@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' -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 { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, 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 ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -21,14 +21,20 @@ type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; di const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || '' const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}` const contexts = ref([]), rows = ref([]), selected = ref([]), warehouseId = ref('') -const loading = ref(false), saving = ref(false), error = ref(null), saveError = ref(null) +const working = ref(false), saving = ref(false), error = ref(null), saveError = ref(null) +const candidatePage = useDispatchCandidates(error, listDispatchCandidates) +const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage +const loading = computed(() => working.value || candidatePage.loading.value) +const selectedRows = ref>({}) +let receivingContexts: Awaited>[] = [] +let nativeGeneration = 0 const capacity = ref(30), result = ref(null), pending = ref(null), corrupted = ref(false) const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false) const existing = ref>([]), baseline = ref('[]') const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission))) const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create')) const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value)) -const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key))) +const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row))) const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value) useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved) const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T @@ -43,7 +49,7 @@ function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encod async function load() { if (!readable.value || pending.value || corrupted.value) return const current = ++generation, batches = [...new Set(props.batchKeys)] - loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = [] + working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++ try { if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次') const receiving = await Promise.all(batches.map(key => getReceivingContext(key))) @@ -66,16 +72,24 @@ async function load() { if (current !== generation) return if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致') requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations))) - contexts.value = batchCandidates - const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))] - const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true } - if (current !== generation) return - requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)]) - if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单') - rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => { - const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId) + contexts.value = batchCandidates; receivingContexts = receiving + await loadCandidatePage(true) + } catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') } + finally { if (current === generation) working.value = false } +} +async function loadCandidatePage(autoSelect = false) { + const sequence = ++nativeGeneration, current = generation + const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))] + if (!planIds.length) { candidatePage.reset(); rows.value = []; return } + await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' }) + if (sequence !== nativeGeneration || current !== generation) return + if (error.value) { rows.value = []; return } + try { + requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches)) + rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => { + const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId) const candidate = matches.length === 1 ? matches[0]! : null - const sources = receiving[index]!.batch.items + const sources = receivingContexts[index]!.batch.items const blocks = [...context.blockReasons, ...row.blockReasons] if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实') if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) } @@ -84,20 +98,25 @@ async function load() { if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致') return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] } })) + if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native) for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围') - const eligible = rows.value.filter(row => !reasons(row).length) - selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : [] - baseline.value = JSON.stringify(selected.value) - } catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') } - finally { if (current === generation) loading.value = false } + if (autoSelect && candidateTotal.value <= candidatePageSize.value) { + const eligible = rows.value.filter(row => !reasons(row).length) + selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : [] + selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)])) + baseline.value = JSON.stringify(selected.value) + } + } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') } } +function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() } +function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() } function select(row: BatchRow, event: Event) { const input = event.target as HTMLInputElement if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return } if (!input.checked) selected.value = selected.value.filter(key => key !== row.key) else if (!selected.value.includes(row.key)) { if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘') - else selected.value.push(row.key) + else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) } } input.checked = selected.value.includes(row.key) } @@ -153,6 +172,7 @@ async function save() { ensureOwner() if (!pending.value) { if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位') + candidatePage.assertCurrent(chosen.value.map(row => row.native!)) pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist() } saving.value = true @@ -200,7 +220,7 @@ async function releaseRejected() { } async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } } onMounted(load) -onBeforeUnmount(() => { generation++ }) +onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })

本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。核对并重试原请求

diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index 36a99b7f..ed2487e6 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -1,5 +1,131 @@ # 海外仓原型核对矩阵 +## 尾程订单汇总文字隐藏(2026-10-20) + +按用户截图`codex-clipboard-168f9aa5-5478-4085-a9b4-d59e7b08ca1f.png`及明确要求,移除查询区下方“仓储转运·全筛选”和“当前筛选中的退货”两段长汇总,不再挂载仅提供转运汇总的组件。订单明细、退货行维度、筛选、分页和错误反馈保持;不删交易数据或改变统计口径。原列表顶部矩阵中的“数量汇总”不再指这两段常驻文字。本页行为回归75项通过,差异检查通过;待人工截图确认。 + +## 2026-10-20 列表顶部紧凑收尾 + +依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。 + +| 页面/操作 | 原承载与本轮调整 | 保留边界/验证 | +|---|---|---| +| 尾程订单/状态切换、查询 | 状态页签移至表格 `toolbar-start`,替代正文重复标题;查询仍在 filters | 保留方向键、Home/End、焦点、选择清空、数量汇总、高级筛选与全部业务按钮;待定向检查和人工截图确认 | +| 排车列表/执行阶段、审核分区与查询 | 主阶段页签并入顶部工具栏;装车前审核子分区保留在筛选区首部及原层级样式 | 服务端阶段数量、返回预约工作、审核分区、未知操作结果和错误重试不变;待定向检查和人工截图确认 | +| 退货库存/库存、进销存、合箱计划 | 已有 Element 页签和图标移至工具栏;筛选仍在 filters | 查询、分页、列宽偏好、创建合箱计划与所有原操作不变;无页签的退货箱号标题保留;待定向检查和人工截图确认 | + +本次仅调整列表顶部插槽位置与公共类,不改变详情、新增、编辑、审核、文件预览或取消等操作承载,不修改业务脚本、API 或权限。 + +## 2026-10-20 候选服务端分页 + +| 页面/操作 | 已确认承载与依据 | 本批实现 | 验收 | +|---|---|---|---| +| 智能排车/选货分组 | 本文“智能排车在途预排与操作整理”矩阵;现行排车契约第17项的服务端分页;`SmartDispatchDialog.vue` 居中弹框与候选/装车组布局 | 候选表下显示总数、页码和10/20/50条选择;筛选与区域页签交给服务端,变化后回第一页;草稿保留跨页完整组 | 自动检查单独记录;1920×1080、100%待人工截图确认 | +| 既有排车/加货 | `DispatchWorkflowDialog.vue` 的既有1120px居中工作流弹框;同一候选分页契约 | 候选表下分页并显示已选完整组数;跨页保留选择,提交继续检查组版本和批次原始版本 | 自动检查单独记录;1920×1080、100%待人工截图确认 | + +## 2026-10-20 智能排车紧凑布局复核 + +### 后续截图:标签、标题与底部操作区对齐 + +用户最新截图显示建单方式/起运仓标签偏上、候选货物与装车分组标题未处于同一高度,且按钮下方留有大块空白。共享密度样式将行内表单标签垂直居中;排车左右标题统一最小高度和居中方式;固定高度工作区的正文填充剩余空间,使页脚落在弹框底部。正文继续承担纵向滚动,表格不新增内层纵向滚动。最终效果仍待1920×1080、100%人工截图确认。 + +用户同时补充无草稿参数的 candidates 查询超过5秒。本轮将来源摘要中的库存、入库实收、退回实收三类查询由逐候选读取改为当前请求内按客户/托盘批读,保持原排序、客户隔离、完整来源组与来源版本;在途计划无实托时不发空查询,写入锁序保持。超过1秒的候选服务读取记录候选、组装完整组、预约引用及响应组装各阶段耗时,不记录业务身份或草稿凭据。此记录不包含网关、网络和JSON传输耗时,不能代替端到端请求测量。 + +最终自动检查:前端定向6文件123项通过,后端5个测试类75项通过(包括同客户批读、跨客户批读、同托客户隔离、退回实收、请求间版本变化及在途零空查);前端类型检查与构建通过。前端全量4203/4204通过,唯一失败为`AdminSidebarMenu.test.ts`的收起侧栏点击后路由期望`/warehouses`但得到`/`;该文件原样单独复核7/7通过,未修改侧栏或放宽断言,完整回归仍记录为有一次失败。完整结果见本地`.logs/smart-dispatch-alignment-20261020.json`。本轮未发布,不能据本地检查断言真实5秒问题已经消除。 + +### 前轮紧凑布局与滚动修正 + +直接依据为用户本轮截图及“对不齐、选择占太多位置、已有1/2步骤无需重复提示”的反馈,覆盖上轮对应视觉结论。 + +| 页面/操作 | 承载与本轮调整 | 验证 | +|---|---|---| +| 智能排车/新建、选货与空单 | 保留居中弹框、原宽度、全屏、分组及提交。第1、2步各采用标题与控件同一行的白底工具栏;起运仓/草稿240px,托位100px;候选查询采用固定业务宽度,右侧分组320px;托盘展开箭头、业务号和数量同一行。删除正文重复步骤引导,操作说明保留信息浮层,错误及空单执行限制直接显示 | 初轮定向133项及类型/构建通过;用户后续截图补充下拉框挤窄与多重滚动问题,最终回归待运行;1920×1080、100%最终页面待用户截图确认 | + +用户随后反馈 candidates 约4秒及三层滚动:起运仓固定表单项宽度,启用共享工作区高度和 body 挂载/滚动锁,移除表格内部纵向限高;共享工作区上下边距对称,避免浮层总高溢出。新建空草稿和装车组不再重复读取候选;后端同页实托可排来源在当前只读事务批读,页外闭包仍补读,正式写入继续原锁内核验。接口、权限、资格与来源版本语义保持;真实接口耗时待环境复测。 + +本轮最终直接排车组件30项通过(真实Teleport挂载后从ElDialogContent读取,不以Teleport桩替代实际结构);共享弹框与国内测量/任务弹框消费者的定向检查通过。后端候选/完整来源/整托换标/草稿54项通过,同客户多托批读次数及请求间版本变化另3项复验通过;构建内含vue-tsc类型检查,通过。完整Admin回归4197/4200通过,另3项公共样式契约失败:`adminDensity.test.ts`的日历表单标签布局与列表分类页签检查,以及`adminWorkbench.test.ts`的工具栏布局属性归属检查;这些失败涉及本轮排车修改范围之外的日历、列表和共享工作台样式,未据此改动对应页面或放宽断言。完整结果见本地`.logs/smart-dispatch-compact-final-20261020.json`。本轮未部署,真实HTTP时延与最终截图尚未验收。 + + +## 2026-10-20 智能排车在途预排与操作整理 + +用户本轮“新建排车”截图(顶部建单设置、车辆司机、草稿,底部才出现候选货物)及澄清“就是入库单的智能排车,离港后,就能排车”为本次直接依据。 + +| 页面 | 操作 | 依据与原承载 | 当前调整 | 验收 | +|---|---|---|---|---| +| 入库单智能排车/排车列表新建 | 新建、先选货、先建空单 | 本轮截图;`components/SmartDispatchDialog.vue` 的 `AppDialog` 居中弹框,1800px 上限、全屏、固定底部提交 | 起运仓与方式 → 选货分组 → 当前组车辆时间;开始选货串行建立草稿和首组;未选组不展示禁用资料;保留独立组设置及原请求恢复 | 前端定向121项、类型检查通过;复用同快照Admin全量4198项及构建通过结果;1920×1080、100%真实页面待人工截图确认 | +| 同一弹框 | 在途预排、草稿继续、分组与提交 | 本轮用户确认离港后可预排;现行服务端 PLAN 来源及来源版本/整组原子命令 | 显示“在途预排(已离港)”筛选与在途计划来源;显示当前操作和生成组数;候选表限高并保留全部业务列、明细及区域页签 | 后端相关53项通过,仓库归属投影补充场景14项复验通过;查询与写入核对真实离港,预约关联按计划量预排,实收后仍须原 PDA 门禁;未部署及真实HTTP验收 | + +本轮自动检查全部经 `baiyue-firstleg/scripts/local-check.ps1`。前端全量结果复用当前工作区 `2026-10-20 21:12:13–21:21:15` 的实际 `vitest run --reporter=json`(4198/4198);当前组件及直接测试分别冻结于21:09:42、21:11:44,定向后未再改动。构建复用21:13:19–21:13:48同快照通过记录;本轮单独执行类型检查通过。后端验证为候选Mapper、完整来源、草稿、PDA拣货、既有FBA预约关联与预排的六组测试,53/53;随后补充已预留但无库存的起运仓投影,所属Mapper14/14复验。自动检查不替代部署、真实HTTP与人工截图。 + +本次不修改详情四页签、既有单据编辑/审核、文件预览及取消承载。沿用已有蓝白主题和公共密度,不新增车辆预约、日历预约或旧预排协议。 + + +## 2026-10-20 蓝白工作台续批(写权已协调) + +本次续批修改文件(相对本模块): + +- `components/DispatchEvidenceFiles.vue` +- `components/DispatchWorkflowDialog.vue` +- `components/SmartDispatchDialog.vue` +- `pages/DispatchDetailView.vue` +- `pages/DispatchesView.vue` + +总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。 + +| 实际入口 | 操作/承载与冻结结论 | 验收 | +|---|---|---| +| 排车列表/详情、智能排车、工作流、资源/安排和凭证弹框;保留四页签、原Gate与作业承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 | + + + +续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。 + +## 2026-10-20 蓝白工作台展示改造 + +本批文件清单(相对本模块): + +- `components/DeliveryDocumentsDialog.vue` +- `components/ExceptionProcessingPanel.vue` +- `components/OverseasDetailShell.vue` +- `components/OverseasReadOnlyDetail.vue` +- `components/WarehouseTransferFulfillmentPanel.vue` +- `components/WarehouseTransferReceiptPanel.vue` +- `components/WarehouseTransferReviewDialog.vue` +- `pages/CargoDiscardsView.vue` +- `pages/CustomerVisitsView.vue` +- `pages/DeliveryOrderDetailView.vue` +- `pages/DeliveryOrdersView.vue` +- `pages/ExceptionOrdersView.vue` +- `pages/InboundOrderDetailView.vue` +- `pages/InboundOrdersView.vue` +- `pages/InventoryDetailView.vue` +- `pages/InventoryTransactionsView.vue` +- `pages/InventoryView.vue` +- `pages/LastMileOrderDetailView.vue` +- `pages/LastMileOrdersView.vue` +- `pages/LocationsView.vue` +- `pages/MojiaSkuSyncView.vue` +- `pages/OutboundDetailView.vue` +- `pages/OutboundsView.vue` +- `pages/PalletDetailView.vue` +- `pages/PalletsView.vue` +- `pages/RelabelOrderDetailView.vue` +- `pages/RelabelOrdersView.vue` +- `pages/ReturnInventoryManagementView.vue` +- `pages/LastMileOrdersView.behavior.test.ts` +- `pages/prototypeContract.test.ts` +- `PROTOTYPE_MATRIX.md` + +依据:[蓝白样板](../../../../../docs/界面参考/蓝白卡片式工作台风格参考.md)、本矩阵既有逐操作承载及[密度核对记录](../../../../../docs/Admin海外仓样式核对记录.md)。只调整外观,业务字段、密度、权限、请求、金额及状态均沿当前实现。 + +| 页面 | 操作与现有承载 | 本轮结论 | 验收 | +|---|---|---|---| +| 尾程订单、入库单、废弃单、换标、库存及流水、托盘、库位、出库、派送、异常、回访、退货库存/箱号、称重机器 | 正式列表、既有查询与行操作 | 显式主题根、公共列表外壳,保留宽表和行高;汇总使用紧凑面板 | 定向回归通过;待人工1920×1080、100%截图确认 | +| 尾程、入库、库存、托盘、换标、出库、派送详情 | 既有独立路由及页签 | 连续信息表与原字段分区保留,蓝白外观显式接入;共享详情壳按调用方选择 | 定向回归通过;待人工截图确认 | +| 上述页面的新建、编辑、审核、确认、附件和预览 | 沿下文逐操作矩阵的现有居中弹框/预览 | 浮层自身接入主题;尺寸、全屏、关闭重开和危险提示保留;打印版面保持原样 | 定向回归通过;待人工截图确认 | +| 排车列表、排车详情、智能排车、排车工作流及其资源/安排/证据组件 | 在途专项 | 首批保留;写权协调后由本次续批接入 | 见顶部续批结论 | + + ### 尾程商业来源展示 | 页面 | 操作 | 依据 | 承载与结论 | 验收 | @@ -53,6 +179,11 @@ 第一批桌面布局按 `1920×1080` 验收:列表使用紧凑查询区与横向宽表;详情使用连续白底、双列或三列紧凑字段分区,长表保持横向滚动。当前缺少人工页面截图,视觉结果待总控按真实页面截图确认。 +本批验证:54个文件609项定向回归,首轮两项旧样式字符串断言更新后,其所属2文件96项复验通过。事件及表单绑定与改造前一致;类型检查、全量测试和构建由总控集成执行。 + +首批曾保留、续批已获得展示写权的文件:`pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue`、`components/DispatchArrangeFields.vue`、`components/DispatchResourceFields.vue`、`components/DispatchEvidenceFiles.vue`。预约模块开始与收尾均有在途的`../appointment-calendar/scheduleApi.ts`及`scheduleApi.test.ts`,首批未改预约页面和组件;本次续批已接入当前实际页面及表单,API仍保留原改动。 + + ## 第二批:换标单、托盘、箱号、产品库 | 页面 | 操作 | 原型证据 | 原型承载方式与尺寸 | 实施结论 | @@ -112,3 +243,7 @@ |---|---|---|---|---| | 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 | | 异常订单 | 详情内开始处理、上传证据、分批实收、取消/完成 | 本轮完整异常退回流程接线授权;保持 `ExceptionOrdersView.vue` 既有760px居中详情弹框 | `ExceptionProcessingPanel.vue`读取权威详情、allowedActions/version;真实托盘库位选择,未知写结果保留原键与载荷,不新增撤拣或多车拆分 | 两批实收、未知结果重试、无权限、版本冲突及切单迟到响应组件交互覆盖;待人工1920×1080/100%截图确认 | + +2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。layoutAcceptance.test.ts仅将旧页头分隔线颜色期望改为当前workbench-border,托盘先于预约及字段列数等布局断言保留。 + +本次整页面层收口:40个Vue文件仅新增class modifier,逐文件内容对照与差异检查通过;layoutAcceptance.test.ts定向6项通过。文件已固定,公共样式及全量门禁由总控收口,人工视觉仍待截图确认。 diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts index c31a7686..7e94b4f7 100644 --- a/src/modules/overseas-warehouse/api/dispatches.test.ts +++ b/src/modules/overseas-warehouse/api/dispatches.test.ts @@ -65,10 +65,10 @@ describe('native whole-vehicle dispatch API', () => { expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1']) }) it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => { - const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true } + const data = { total: 1, page: 1, size: 20, pages: 1, candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true } vi.mocked(http.get).mockResolvedValueOnce({ data }) - expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data) - expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } }) + expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' })).toEqual(data) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, paramsSerializer: { indexes: null } }) }) it('creates an empty real dispatch without an artificial customer or cargo', async () => { const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] } diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts index 6ca05377..4b177c2a 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -54,7 +54,7 @@ beforeEach(() => { 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.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, 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' })) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts index 6104a9de..44e2c0a6 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts @@ -16,7 +16,7 @@ vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi interface State { submit(): Promise; retryError(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: 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: '' }) + vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' }) const { wrapper, state } = await open('ADD_CARGO') const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable, checked: boolean): void } selection.selectCargo(first!, true) @@ -25,6 +25,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo first!.selectionGroup.complete = false; selection.selectCargo(first!, false) await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整') first!.selectionGroup.complete = true + await (wrapper.vm as unknown as { searchCargo(): Promise }).searchCargo() 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] })) @@ -32,7 +33,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo }) 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 }] }] }) - 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({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }) const { wrapper, state } = await open('ADD_CARGO') try { (wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true) @@ -43,7 +44,7 @@ it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s }) it('blocks cargo addition when the authoritative batch version is absent', async () => { const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] }) - 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({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }) const { wrapper, state } = await open('ADD_CARGO') try { (wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit() @@ -234,3 +235,30 @@ it('describes a proven success with a failed GET as completed and retries only i expect(wrapper.emitted('saved')).toHaveLength(1) wrapper.unmount() }) + +it('preserves selected complete groups across pages and submits hidden sources with their original versions', async () => { + const [first, hidden] = sharedPalletCandidates(), second = candidate({ sourceKey: 'next-page' }) + vi.mocked(api.listDispatches).mockResolvedValue(page()) + vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [second] : [first!], page: query.page!, size: query.size!, total: 21, pages: 2, complete: false, snapshotAt: '' })) + const { wrapper, state } = await open('ADD_CARGO') + try { + const selection = wrapper.vm as unknown as { selectCargo(row: typeof second, checked: boolean): void; changeCandidatePage(value: number): void; candidatePageNumber: number; candidateKeyword: string; searchCargo(): Promise } + selection.selectCargo(first!, true); selection.changeCandidatePage(2); await flushPromises(); selection.selectCargo(second, true); await flushPromises() + expect(wrapper.text()).toContain('已选择 2 个完整来源组') + selection.candidateKeyword = 'next'; await selection.searchCargo() + expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'next' })) + expect(api.listDispatches).toHaveBeenCalledTimes(1) + 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).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first!.selection, hidden!.selection, second.selection] })) + } finally { wrapper.unmount() } +}) +it('shows authoritative warehouse lookup failures when retrying a cargo search', async () => { + vi.mocked(api.listDispatches).mockRejectedValue(new Error('warehouse lookup unavailable')) + const { wrapper } = await open('ADD_CARGO') + try { + await (wrapper.vm as unknown as { searchCargo(): Promise }).searchCargo() + expect(wrapper.text()).toContain('货物候选读取失败') + expect(api.listDispatchCandidates).not.toHaveBeenCalled() + } finally { wrapper.unmount() } +}) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 0d68e2ab..9e7053ae 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -1,4 +1,5 @@