+
-
正在读取当前事项与操作资格…
+
{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}
- {{ detail.item.title }}
{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}
- {{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}
{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}
- 下次跟进:{{ detail.item.followupDate || '尚未安排' }} · 作业日期未定,跟进日不作为作业日期
- {{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。
-
- {{ workReason(reason) }}
+
+ {{ detail.item.title }}
{{ workStatus(detail.item) }}
+ {{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }}
+
+
+ - 作业时间
- {{ workDate(detail.item) }}
+ - 作业时区
- {{ detail.item.timezone || '地点时区待核实' }}
+ - 作业地点
- {{ detail.item.destinationName || detail.item.originName || '地点待核实' }}
+ - 负责人
- {{ detail.item.owner?.name || '待分派' }}
+ - 下次跟进
- {{ detail.item.followupDate || '尚未安排' }}作业日期未定,跟进日不作为作业日期
+
+ 关联货柜{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办
+
还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}
+
+ {{ detail.item.executionStatus === 'COMPLETED' ? '事项结果' : '当前处理' }}
+ 当前事项已完成,可查看关联工作和历史记录。
本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}
{{ detail.item.nextAction.label }}
@@ -128,15 +158,43 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
emit('external', path)" @batch="openBatch" />
emit('external', path)" />
emit('external', path)" />
- 原业务进度
{{ reference.label }} · {{ reference.businessNo }}{{ reference.label }} · {{ reference.businessNo || '—' }}
+ 原业务进度
{{ reference.label }} · {{ reference.businessNo }}{{ reference.label }} · {{ reference.businessNo || '—' }}
更多操作
{{ candidate.label }}
- 关联工作 · {{ detail.relatedItems.length }} 项
- 原业务记录 · {{ detail.references.length }} 项
{{ reference.label }} · {{ reference.businessNo || '—' }}
- 操作记录
{{ entry.action }}{{ entry.summary }}
{{ entry.operatorName || '—' }} · {{ entry.occurredAt }} 暂无记录
+
+
+ 关联工作 · {{ detail.relatedItems.length }} 项
暂无关联工作
+ 原业务记录 · {{ detail.references.length }} 项
{{ reference.label }} · {{ reference.businessNo || '—' }}
暂无原业务记录
+ 操作记录 · {{ detail.history.length }} 条
{{ historyLabels[entry.action] || '事项记录' }}
{{ entry.summary }}
{{ entry.operatorName || '—' }} · {{ entry.action }}暂无记录
+
diff --git a/src/modules/appointment-calendar/WorkDispatchForm.test.ts b/src/modules/appointment-calendar/WorkDispatchForm.test.ts
index 52cbd909..1d70a290 100644
--- a/src/modules/appointment-calendar/WorkDispatchForm.test.ts
+++ b/src/modules/appointment-calendar/WorkDispatchForm.test.ts
@@ -1,94 +1,189 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
-import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
+import { createDispatch, getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
+import { ApiResponseError, http } from '@/shared/api/http'
+import { parseApiJsonResponse } from '@chongchong/shared'
+import { getReceivingContext, type ReceivingContext } from './receivingApi'
+import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
-const access = vi.hoisted(() => ({ allowed: true }))
-vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
+const access = vi.hoisted(() => ({ allowed: true, denied: [] as string[] }))
+const identity = vi.hoisted(() => ({ id: '' }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
+vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
-vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original
(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
-const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
-let guard: LeaveGuard
-function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '' }, ElButton: { props: ['disabled'], template: '' }, LoadErrorState: { props: ['message'], template: '{{ message }}
' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '' } } } }) }
-function button(wrapper: ReturnType, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
+vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), createDispatch: vi.fn(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
+vi.mock('./receivingApi', async original => ({ ...await original(), getReceivingContext: vi.fn() }))
+const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T
+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 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 { 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 {
+ 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: [], 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 } }
+let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
+function render(props: { batchKeys?: string[]; canWrite?: boolean } = {}) { return mount(WorkDispatchForm, { props: { batchKeys: [batch], canWrite: true, ...props }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '' }, ElButton: { props: ['disabled'], template: '' }, LoadErrorState: { props: ['message'], template: '{{ message }}
' }, ApiErrorMeta: true } } }) }
+const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
+it.each([0, '0', '9007199254740993'])('preserves authoritative allocation version %s from the existing batch provider', async recordVersion => {
+ const value = row(); value.allocations[0]!.recordVersion = recordVersion
+ vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [value], blockReasons: [] })
+ const wrapper = render()
+ try {
+ await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
+ expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations[0]!.recordVersion).toBe(recordVersion)
+ } finally { wrapper.unmount() }
+})
+it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
+ const value = row(); value.allocations[0]!.recordVersion = recordVersion as unknown as number
+ vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 0, candidates: [value], blockReasons: [] })
+ const wrapper = render()
+ try { await flushPromises(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选'); expect(createDispatchDraft).not.toHaveBeenCalled() }
+ finally { wrapper.unmount() }
+})
beforeEach(() => {
- vi.clearAllMocks(); access.allowed = true
- vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
- vi.mocked(listDispatchDrafts).mockResolvedValue([])
- vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
- vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
+ vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; access.denied = []
+ 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 () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
+ vi.mocked(createDispatch).mockImplementation(async body => proof(body.idempotencyKey))
+ 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) })
+ vi.mocked(convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: [{ draftContainerId: 'container-1', dispatch: proof(body.groups[0]!.dispatch.idempotencyKey) }] }))
+ vi.mocked(getDispatch).mockImplementation(async no => detail(no)); vi.mocked(listDispatchDrafts).mockImplementation(async () => [clone(draft)])
+ vi.mocked(releaseDispatchDraft).mockImplementation(async () => ({ ...clone(draft), status: 'RELEASED', containers: [], sources: [] }))
})
-it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
- const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
- await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
- expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
- expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
- const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
- expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
- prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
- wrapper.unmount()
+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: { 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 {
+ await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
+ await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
+ expect(createDispatch).toHaveBeenCalledOnce(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations)
+ expect(vi.mocked(createDispatch).mock.calls[0]![0].cargo[0]!.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
+ } finally { wrapper.unmount(); get.mockRestore() }
})
-it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => {
- const wrapper = render(); await flushPromises()
- await button(wrapper, '按当前批次箱数分批').trigger('click')
- expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']])
- expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
+it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
+ vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS/EXISTING', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
+ for (let index = 0; index < 2; index++) {
+ const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
+ await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
+ }
+ expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(createDispatch).not.toHaveBeenCalled()
})
-it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => {
- vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] })
- const wrapper = render(); await flushPromises()
- const inputs = wrapper.findAll('input[type=checkbox]')
- expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false])
- expect(inputs[1]!.attributes('disabled')).toBeDefined()
- await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
- expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId])
- expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations)
- wrapper.unmount()
+it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
+ vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
+ vi.mocked(getReceivingContext).mockResolvedValueOnce(receiving(batch, [{ dispatchNo: 'DS-OLD', batchKey: batch, customerId: 'customer-b', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
+ const mismatch = render(); await flushPromises(); expect(mismatch.text()).toContain('排车单与工作批次关联不一致'); expect(mismatch.text()).not.toContain('核对本批剩余可排实物'); expect(createDispatchDraft).not.toHaveBeenCalled(); mismatch.unmount()
})
-it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
- vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
- const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
- await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
- const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
- expect(await guard()).toBe(false)
- await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
- expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
- wrapper.unmount()
+it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
+ vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
+ const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
+ await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(createDispatch).not.toHaveBeenCalled(); wrapper.unmount()
})
-it('recovers a draft container from the same token when its creation response was lost', async () => {
- vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
- const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
- await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
- const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
- await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
- expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
+it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
+ const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
+ expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
+ expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
+ await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
})
-it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
- vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
- const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
- expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
- await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
- expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
- await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
- expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
+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[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
+ await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = [{ dispatch: vi.mocked(createDispatch).mock.calls[0]![0] }]
+ 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()
})
-it('does not read or write scoped cargo without all original permissions', async () => {
- access.allowed = false; const wrapper = render(); await flushPromises()
- expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
+it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
+ const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
+ vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
+ const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
+ await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
+})
+it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
+ 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[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(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations); wrapper.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(createDispatch).not.toHaveBeenCalled(); cross.unmount()
})
-it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => {
- const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] }
- vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] })
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }])
- const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
- await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises()
- const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' })
- expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true })
- expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations)
- shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined()
- shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']])
- wrapper.unmount()
+
+it('shows the confirmed dispatch link even when its detail read fails', async () => {
+ vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
+ const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
+ expect(createDispatch).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toHaveLength(1)
+ await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); expect(await guard()).toBe(true); wrapper.unmount()
+})
+
+it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
+ access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
+ await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
+ await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(createDispatch).toHaveBeenCalledTimes(1)
+ await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
+})
+it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
+ access.allowed = false; const denied = render(); await flushPromises(); expect(getReceivingContext).not.toHaveBeenCalled(); denied.unmount()
+ access.allowed = true; access.denied = ['platform:overseas-warehouse:dispatch:create']; const readonly = render(); await flushPromises(); expect(button(readonly, '创建整车排车单').attributes('disabled')).toBeDefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); readonly.unmount()
+})
+it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
+ vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
+ 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(createDispatch).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(createDispatch).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(createDispatch).mock.calls[0]![0].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() }
+})
+
+it('failure preserves selection, releases navigation and creates a fresh request on the next click', async () => {
+ vi.mocked(createDispatch).mockRejectedValueOnce(new Error('timeout'))
+ const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
+ const original = vi.mocked(createDispatch).mock.calls[0]![0]
+ expect(wrapper.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
+ expect(await guard()).toBe(true); expect(sessionStorage.length).toBe(0)
+ await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
+ expect(vi.mocked(createDispatch).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey); expect(getReceivingContext).toHaveBeenCalledTimes(1); wrapper.unmount()
})
diff --git a/src/modules/appointment-calendar/WorkDispatchForm.vue b/src/modules/appointment-calendar/WorkDispatchForm.vue
index 159e863c..86aa7823 100644
--- a/src/modules/appointment-calendar/WorkDispatchForm.vue
+++ b/src/modules/appointment-calendar/WorkDispatchForm.vue
@@ -1,102 +1,163 @@
-
diff --git a/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts b/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts
index 05d909fd..074fe88f 100644
--- a/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts
+++ b/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts
@@ -1,29 +1,42 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
-import { getDispatch } from '@/modules/overseas-warehouse'
-const state = vi.hoisted(() => ({ allowed: true }))
-vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
+import { getDispatch, type DispatchDetail } from '@/modules/overseas-warehouse'
+import { resolveBatchWork } from './workApi'
+import { workDetail, workItem } from './work.test-support'
+const access = vi.hoisted(() => ({ allowed: true }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), getDispatch: vi.fn() }))
-function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, LoadErrorState: { props: ['message'], template: '{{ message }}
' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '' } } } }) }
-beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
-it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
- const wrapper = render(); await flushPromises()
- expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
- await wrapper.findAll('button').find(button => button.text() === '继续安排本批车辆')!.trigger('click')
- const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
- dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
- expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
+vi.mock('./workApi', async original => ({ ...await original(), 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: [], 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: '' }, LoadErrorState: { props: ['message'], template: '{{ message }}
' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '' } } } }) }
+const button = (wrapper: ReturnType, 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: [] }) })
+it.each(['customer-b', undefined])('rejects a FBA reference outside the exact batch customer: %s', async customerId => {
+ const value = detail(); value.dispatchInfo.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', recordVersion: 0, sources: [] }]; vi.mocked(getDispatch).mockResolvedValue(value)
+ const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: value.dispatchNo, customerId }]); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
})
-it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
- vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], status: 'PENDING_DEPARTURE' })
- const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
+it('reads a shared whole dispatch once and verifies every nested batch and real customer', async () => {
+ const value = detail(); value.dispatchInfo.appointmentBatches = ['a', 'b'].map((customerId, index) => ({ batchKey: `FBA_APPOINTMENT:${index + 1}`, batchNo: `AP-${index + 1}`, customerId, recordVersion: 0, sources: [] })); vi.mocked(getDispatch).mockResolvedValue(value)
+ const wrapper = render(value.dispatchInfo.appointmentBatches.map(row => ({ batchKey: row.batchKey, customerId: row.customerId, dispatchNo: value.dispatchNo }))); await flushPromises()
+ expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.findAll('tbody tr')).toHaveLength(1); expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
})
-it('opens an already bound real trip instead of preparing another one', async () => {
- vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
- const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('继续安排本批车辆')
- await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
+it('resolves the opaque dispatch batch in the work domain and opens the original independent detail', async () => {
+ const wrapper = render(); await flushPromises(); expect(resolveBatchWork).toHaveBeenCalledWith('DISPATCH:9007199254740999', undefined)
+ await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FORIGINAL']])
+ expect(wrapper.text()).not.toContain('9007199254740999'); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
})
-it('does not read the dispatch without its original list permission', async () => {
- state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount()
+it('rejects mismatched authority or returned business identity instead of deriving an internal ID', async () => {
+ vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'DISPATCH:9007199254740999', customerId: 'a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-OTHER' }) }), candidates: [] })
+ const wrapper = render(); await flushPromises(); expect(wrapper.find('table').exists()).toBe(false); expect(wrapper.text()).toContain('关联不一致'); wrapper.unmount()
+ vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.find('table').exists()).toBe(false); wrong.unmount()
+})
+it('opens same-dispatch adding only under the server gate, and keeps already scanned cargo out of the old merge flow', async () => {
+ const wrapper = render(); await flushPromises(); await button(wrapper, '为本车添加同仓货物').trigger('click')
+ expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props()).toMatchObject({ dispatchNo: 'DS/ORIGINAL', canWrite: true }); wrapper.unmount()
+ const value = detail(); value.stage = 'LOADING'; value.counts.pickedPackageCount = 1; value.actions.ADD_CARGO!.allowed = false; vi.mocked(getDispatch).mockResolvedValueOnce(value)
+ const scanned = render(); await flushPromises(); expect(scanned.text()).not.toContain('为本车添加同仓货物'); expect(scanned.text()).not.toContain('共车'); scanned.unmount()
+})
+it('does not read dispatch or batch authority without the actual list permissions', async () => {
+ access.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
})
diff --git a/src/modules/appointment-calendar/WorkExistingDispatchForm.vue b/src/modules/appointment-calendar/WorkExistingDispatchForm.vue
index f86afbf7..a038bf2d 100644
--- a/src/modules/appointment-calendar/WorkExistingDispatchForm.vue
+++ b/src/modules/appointment-calendar/WorkExistingDispatchForm.vue
@@ -1,38 +1,61 @@
- 原排车单安排用车
客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。
- 查看原排车单需要排车列表权限。
正在核对原排车单与批次…
+ 沿用原整车排车 在原排车单维护车辆、司机和货物;一张排车单就是一趟车。
+ 查看原排车单及批次关联需要相应列表权限。
正在核对原排车单与批次…
- | 操作 | 排车单号 | 起运仓 | 箱数 | 车次 |
|---|
| 继续安排本批车辆查看原排车单 | {{ row.dispatchNo }} | {{ row.overseasWarehouseName || '起运仓待核实' }} | {{ row.packageCount ?? '待核实' }} | {{ row.workflow.tripNo }}尚未关联车次 |
-
+ | 操作 | 排车单号 | 车牌号 | 箱数 | 阶段 |
|---|
| 继续维护本车为本车添加同仓货物查看原排车单 | {{ row.dispatchNo }} | {{ row.vehicleInfo.vehicle?.plate || '—' }} | {{ row.counts.packageCount }} | {{ stageLabel(row.stage) }} |
+ emit('external', value)" />
diff --git a/src/modules/appointment-calendar/WorkReceiptPlans.test.ts b/src/modules/appointment-calendar/WorkReceiptPlans.test.ts
index d0e91b62..d9657398 100644
--- a/src/modules/appointment-calendar/WorkReceiptPlans.test.ts
+++ b/src/modules/appointment-calendar/WorkReceiptPlans.test.ts
@@ -15,24 +15,25 @@ beforeEach(() => { vi.clearAllMocks(); state.canRead = true; vi.mocked(getContai
it('opens a real plan in the exact customer scope while leaving missing plans unavailable', async () => {
const wrapper = render(); await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledExactlyOnceWith('9007199254740993')
- await wrapper.findAll('button')[0]!.trigger('click')
+ const plans = wrapper.findAll('button').filter(item => item.text() === '查看原接仓记录')
+ await plans[0]!.trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/inbound-orders/IP-001?customerId=9007199254740999']])
- expect(wrapper.findAll('button')[1]!.attributes('disabled')).toBeDefined()
+ expect(plans[1]!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('internal-plan'); expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
})
it('does not read the container source without its original permission', async () => {
state.canRead = false; const wrapper = render(); await flushPromises(); expect(getContainerPlanProgress).not.toHaveBeenCalled(); wrapper.unmount()
})
-it('submits the actual preview snapshot and replays the same request after an unknown result', async () => {
+it('submits the actual preview snapshot and permits a fresh click after an unknown result', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview())
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new Error('lost response')).mockResolvedValueOnce([])
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
const original = vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0]!
expect(original).toEqual([{ sourceContainerBookingId: '9007199254740993', snapshotVersion: 4, snapshotHash: 'snapshot-4' }, expect.any(String)])
- expect(await guard()).toBe(false)
- await button(wrapper, '重试本次计划生成').trigger('click'); await flushPromises()
- expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]).toEqual(original)
+ expect(await guard()).toBe(true)
+ await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
+ expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![0]).toEqual(original[0]); expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![1]).not.toBe(original[1])
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('uses the original resource recovery snapshot and key instead of creating new plans', async () => {
diff --git a/src/modules/appointment-calendar/WorkReceiptPlans.vue b/src/modules/appointment-calendar/WorkReceiptPlans.vue
index ca4ba460..a404e91b 100644
--- a/src/modules/appointment-calendar/WorkReceiptPlans.vue
+++ b/src/modules/appointment-calendar/WorkReceiptPlans.vue
@@ -1,4 +1,5 @@
- 选择本柜入仓计划
实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。
读取本柜计划需要尾程订单列表权限。
+ 选择本柜入仓计划实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。
读取本柜计划需要尾程订单列表权限。
{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}
{{ detail.summary.blockingReason }}
| 操作 | 入库单号 | 入仓码 / 订单号 |
|---|
| 查看原接仓记录 | {{ row.planNo || '尚未生成有效计划' }} | {{ row.inboundCode || row.orderNo || '—' }} |
本柜尚无可读取的入仓来源。
核对本柜入库计划预览
- 核对本柜计划与托盘
{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理
头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。
| 客户 | 原票 | 目的地 | 箱数 | 目标托数 | 计划状态 |
|---|
| {{ row.customerName || '客户名称待核实' }} | {{ row.inboundCode || row.businessOrderNo || '—' }} | {{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }} | {{ row.packageCount }} | {{ row.targetPalletCount }} | {{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }} |
本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。
{{ pending ? '重试本次计划生成' : preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}
+ 核对本柜计划与托盘
{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理
头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。
| 客户 | 原票 | 目的地 | 箱数 | 目标托数 | 计划状态 |
|---|
| {{ row.customerName || '客户名称待核实' }} | {{ row.inboundCode || row.businessOrderNo || '—' }} | {{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }} | {{ row.packageCount }} | {{ row.targetPalletCount }} | {{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }} |
本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。
{{ preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}
diff --git a/src/modules/appointment-calendar/WorkReceivingPanel.test.ts b/src/modules/appointment-calendar/WorkReceivingPanel.test.ts
index cab16ccc..fa91ccef 100644
--- a/src/modules/appointment-calendar/WorkReceivingPanel.test.ts
+++ b/src/modules/appointment-calendar/WorkReceivingPanel.test.ts
@@ -1,3 +1,4 @@
+import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
@@ -26,6 +27,7 @@ function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreeme
function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } }
function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } }
function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
+ ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['disabled', 'nativeType'], template: '' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '' },
LoadErrorState: { props: ['message', 'showRetry'], template: '' }, ApiErrorMeta: true,
@@ -234,7 +236,7 @@ function mixedPlans(): api.ReceivingContext {
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' })
c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null })
- c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
+ c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING', plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
return c
}
async function enterBPlan(wrapper: ReturnType) {
@@ -371,7 +373,7 @@ it('inherits unconfirmed legacy header plans only without active independent cov
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
for (const ownPlan of [false, true]) {
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
- if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }]
+ if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING' }) }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
diff --git a/src/modules/appointment-calendar/WorkReceivingPanel.vue b/src/modules/appointment-calendar/WorkReceivingPanel.vue
index 2755ad19..e8211a6f 100644
--- a/src/modules/appointment-calendar/WorkReceivingPanel.vue
+++ b/src/modules/appointment-calendar/WorkReceivingPanel.vue
@@ -1,4 +1,7 @@
-
diff --git a/src/modules/appointment-calendar/appointmentNativeForms.css b/src/modules/appointment-calendar/appointmentNativeForms.css
new file mode 100644
index 00000000..fd002e1c
--- /dev/null
+++ b/src/modules/appointment-calendar/appointmentNativeForms.css
@@ -0,0 +1,47 @@
+/* Native work-command fields share the existing input theme, with local form layout. */
+.appointment-native-form {
+ min-width: 0;
+}
+
+.appointment-native-form > fieldset {
+ display: grid;
+ min-width: 0;
+ gap: var(--admin-space-5);
+ margin: var(--admin-space-3) 0;
+ padding: 0;
+ border: 0;
+}
+
+.appointment-native-form > fieldset > .el-button { justify-self: start; }
+
+.appointment-native-form label:has(> :is(input, select, textarea, .el-date-editor)) {
+ display: grid;
+ min-width: 0;
+ gap: var(--admin-space-2);
+ overflow-wrap: anywhere;
+}
+
+.appointment-native-form label > :is(.workbench-input, .el-date-editor) {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.appointment-native-form label > textarea {
+ resize: vertical;
+}
+
+.appointment-native-form label:has(> input[type='checkbox']) {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.appointment-native-form .appointment-form-actions {
+ position: sticky;
+ z-index: 1;
+ bottom: 0;
+ padding-block: var(--admin-space-3);
+ border-top: 1px solid var(--workbench-border);
+ background: var(--workbench-surface);
+}
diff --git a/src/modules/appointment-calendar/calendar.test.ts b/src/modules/appointment-calendar/calendar.test.ts
index bfd0c581..3da14ba4 100644
--- a/src/modules/appointment-calendar/calendar.test.ts
+++ b/src/modules/appointment-calendar/calendar.test.ts
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
describe('appointment calendar', () => {
- it('shows six complete Monday-first weeks across year boundaries', () => {
- const dates = calendarDates('2027-01-15', 'month')
- expect(dates).toHaveLength(42)
- expect(dates[0]).toBe('2026-12-28')
- expect(dates.at(-1)).toBe('2027-02-07')
- expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
+ it.each([
+ ['2026-10-19', '2026-09-28', '2026-11-02', 35],
+ ['2027-01-15', '2026-12-28', '2027-02-01', 35],
+ ['2027-02-15', '2027-02-01', '2027-03-01', 28],
+ ['2026-08-15', '2026-07-27', '2026-09-07', 42],
+ ] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => {
+ const dates = calendarDates(anchor, 'month')
+ expect(dates).toHaveLength(count)
+ expect(dates[0]).toBe(from)
+ expect(dates.at(-1)).toBe(addDays(to, -1))
+ expect(calendarRange(anchor, 'month')).toEqual({ from, to })
})
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
diff --git a/src/modules/appointment-calendar/calendar.ts b/src/modules/appointment-calendar/calendar.ts
index 0c4257fc..e6c2ba3d 100644
--- a/src/modules/appointment-calendar/calendar.ts
+++ b/src/modules/appointment-calendar/calendar.ts
@@ -29,16 +29,19 @@ export function addDays(value: string, days: number): string {
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
const date = parseDate(anchor)
+ const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12)
if (mode === 'month') date.setDate(1)
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
const from = dateKey(date)
- // A half-open interval also works directly with database timestamps.
- return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
+ // 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。
+ return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) }
}
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
- const { from } = calendarRange(anchor, mode)
- return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
+ const { from, to } = calendarRange(anchor, mode)
+ const dates: string[] = []
+ for (let date = from; date < to; date = addDays(date, 1)) dates.push(date)
+ return dates
}
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
diff --git a/src/modules/appointment-calendar/index.ts b/src/modules/appointment-calendar/index.ts
index b6fce0af..fddb4e09 100644
--- a/src/modules/appointment-calendar/index.ts
+++ b/src/modules/appointment-calendar/index.ts
@@ -1,2 +1,4 @@
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
+export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
+export type { PlanningSourceLookup } from './planningApi'
diff --git a/src/modules/appointment-calendar/planningApi.test.ts b/src/modules/appointment-calendar/planningApi.test.ts
index 803bdf4c..d25f0444 100644
--- a/src/modules/appointment-calendar/planningApi.test.ts
+++ b/src/modules/appointment-calendar/planningApi.test.ts
@@ -1,6 +1,6 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
+import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: { data: T }) => value.data }))
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
@@ -20,6 +20,15 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
expect(result.total).toBe(0)
})
+it('uses authoritative destination options and sends the selected destination to server pagination', async () => {
+ vi.mocked(http.get).mockClear()
+ vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']})
+ expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8'])
+ expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined})
+ vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}})
+ await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'})
+ expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined})
+})
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
diff --git a/src/modules/appointment-calendar/planningApi.ts b/src/modules/appointment-calendar/planningApi.ts
index 88f6f007..77762ff3 100644
--- a/src/modules/appointment-calendar/planningApi.ts
+++ b/src/modules/appointment-calendar/planningApi.ts
@@ -31,7 +31,7 @@ export interface PlanningCargo {
mixedDestinations: boolean
}
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
-export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
+export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) {
const response = await http.get, ApiEnvelope>(
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
)
@@ -39,6 +39,12 @@ export async function listPlanningCargo(query: { page: number; size: number; key
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
}
+export async function listPlanningDestinations(signal?: AbortSignal) {
+ return unwrapApiResponse(await http.get, ApiEnvelope>(
+ '/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal },
+ ))
+}
+
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record }
diff --git a/src/modules/appointment-calendar/schedule.test.ts b/src/modules/appointment-calendar/schedule.test.ts
new file mode 100644
index 00000000..c8693aff
--- /dev/null
+++ b/src/modules/appointment-calendar/schedule.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { projectContainerSchedule } from './schedule'
+import type { CalendarSources, WarehouseArrival } from './scheduleApi'
+const range = { from: '2026-11-01', to: '2026-12-01' }
+const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
+const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true, plannedDate: '2026-11-01', isDefault: true }
+describe('four-type schedule projection', () => {
+ it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
+ const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, planCount: 1, isDefault: false, canEdit: false }], range, true)
+ expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
+ })
+ it('adds two local calendar days across months without creating a time', () => {
+ const events = projectContainerSchedule(sources, [warehouse], range, true)
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '', title: 'D0001 计划到仓' })
+ })
+ it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
+ const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
+ expect(projectContainerSchedule(changed, [{ ...warehouse, plannedDate: '2026-11-04' }], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
+ expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1, isDefault: false }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
+ })
+ it('keeps manually moved containers visible when their arrival is outside this month', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
+ expect(events).toHaveLength(1)
+ expect(events[0]?.date).toBe('2026-11-08')
+ })
+ it('shows a date-only frozen plan as frozen without manufacturing midnight', () => {
+ const events = projectContainerSchedule(sources, [{ ...warehouse, planCount: 1, isDefault: false, canEdit: false }], range, true)
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({ date: '2026-11-01', time: '', subtitle: '已有入库计划,日期已冻结', canEdit: false })
+ expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-01' })
+ })
+ it('keeps cross-month frozen date-only cards and their details readable without a port-arrival row', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedDate: '2026-11-08', planCount: 1, isDefault: false, canEdit: false }], range, true)
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({ date: '2026-11-08', time: '', title: 'D0001 计划到仓', subtitle: '已有入库计划,日期已冻结' })
+ expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-08' })
+ })
+ it('does not replace the planned date with PDA actual arrival', () => {
+ const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
+ expect(event.date).toBe('2026-11-05')
+ expect(event.canEdit).toBe(false)
+ expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
+ })
+ it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, cabinetNo: null, arrivalAt: null }] }, [{ ...warehouse, plannedDate: null }], range, false)
+ expect(events.map(row => row.date)).toEqual([null, null])
+ expect(events.every(row => row.sourcePath === undefined)).toBe(true)
+ expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
+ })
+ it('does not invent default plans when the authoritative warehouse query failed', () => {
+ expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
+ })
+ it('uses the actual return date after completion while preserving the deadline in details', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
+ expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
+ })
+})
diff --git a/src/modules/appointment-calendar/schedule.ts b/src/modules/appointment-calendar/schedule.ts
new file mode 100644
index 00000000..61553d59
--- /dev/null
+++ b/src/modules/appointment-calendar/schedule.ts
@@ -0,0 +1,72 @@
+import { projectWarehouseArrival } from '@/modules/container-loading'
+import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
+
+export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
+export type ScheduleType = typeof scheduleTypes[number]
+export const scheduleLabels: Record = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
+export interface ScheduleEvent {
+ key: string
+ type: ScheduleType
+ date: string | null
+ time: string
+ title: string
+ subtitle?: string
+ fields: Array<{ label: string; value?: string | null }>
+ sourcePath?: string
+ warehouse?: WarehouseArrival
+ canEdit?: boolean
+}
+const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
+const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
+
+export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
+ const events: ScheduleEvent[] = []
+ const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
+ const containerMap = new Map(sources.containers.map(row => [row.containerBookingId, row]))
+ for (const row of sources.containers) {
+ const label = row.cabinetNo || '—'
+ const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
+ { label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
+ const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
+ const date = row.arrivalAt?.slice(0, 10) || null
+ if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
+ time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
+ subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
+ fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
+ // 读取失败时不能用默认值遮掉无法读取的人工安排。
+ if (warehouses === null) continue
+ const warehouse = warehouseMap.get(row.containerBookingId)
+ warehouseMap.delete(row.containerBookingId)
+ const projected = projectWarehouseArrival(warehouse)
+ const planned = projected.date
+ if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
+ time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
+ canEdit: warehouse?.canEdit === true,
+ subtitle: !planned ? '柜内计划时间不一致,待核实' : warehouse && warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : projected.isDefault ? '到港日期+2个自然日(默认)' : '人工维护',
+ fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
+ { label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
+ { label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
+ }
+ // 人工改期可能跨月,与当前月份的到港记录无关。
+ for (const row of warehouseMap.values()) {
+ const projected = projectWarehouseArrival(row)
+ const date = projected.date
+ if (!date || !inRange(date, range)) continue
+ events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
+ title: `${row.cabinetNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
+ sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
+ fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
+ { label: '计划到仓', value: row.plannedAt ? timestamp(row.plannedAt) : date }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
+ }
+ for (const row of sources.emptyReturns) {
+ const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
+ if (!at || !inRange(at.slice(0, 10), range)) continue
+ events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
+ title: `${containerMap.get(row.containerBookingId)?.cabinetNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
+ subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
+ sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
+ fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
+ { label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
+ }
+ return events
+}
diff --git a/src/modules/appointment-calendar/scheduleApi.test.ts b/src/modules/appointment-calendar/scheduleApi.test.ts
new file mode 100644
index 00000000..6c7cb3f2
--- /dev/null
+++ b/src/modules/appointment-calendar/scheduleApi.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { projectContainerSchedule } from './schedule'
+import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
+
+vi.mock('@/shared/api/http', async original => ({
+ ...await original(),
+ http: { get: vi.fn(), post: vi.fn() },
+}))
+
+const range = { from: '2026-09-28', to: '2026-11-02' }
+const bookingId = '9007199254740993'
+// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
+const wire = {
+ sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
+ warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
+ containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
+ vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
+ arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
+ emptyReturns: [], emptyReturnsReadable: true,
+ } })
+})
+
+async function readSchedule(overrides: Record = {}) {
+ vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
+ const sources = await getCalendarSources(range)
+ const warehouses = await getWarehouseArrivals(range, [bookingId])
+ return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
+}
+
+describe('warehouse calendar response to schedule', () => {
+ it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
+ const { warehouses, event } = await readSchedule({ planCount, plannedDate: '2026-10-22', isDefault: true })
+ expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
+ expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
+ expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
+ expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
+ })
+
+ it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
+ const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
+ expect(warehouses[0]?.planCount).toBe(2)
+ expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
+ })
+
+ it('keeps a manually maintained time when there is no inbound plan', async () => {
+ const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
+ expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
+ })
+
+ it('does not calculate a default when existing plans have no unified time', async () => {
+ const { event } = await readSchedule({ planCount: '2', canEdit: false })
+ expect(event?.date).toBeNull()
+ })
+
+ it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
+ const { event } = await readSchedule({ version: -1, canEdit: false })
+ expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
+ })
+
+ it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
+ 'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
+ await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
+ },
+ )
+})
diff --git a/src/modules/appointment-calendar/scheduleApi.ts b/src/modules/appointment-calendar/scheduleApi.ts
new file mode 100644
index 00000000..fce84c0d
--- /dev/null
+++ b/src/modules/appointment-calendar/scheduleApi.ts
@@ -0,0 +1,73 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
+export interface CalendarContainer {
+ containerBookingId: string
+ cabinetNo: string | null
+ containerNo: string | null
+ vesselName: string | null
+ voyageNo: string | null
+ destinationPortName: string | null
+ destinationWarehouseCode: string | null
+ arrivalAt: string | null
+ arrivalType: 'ACTUAL' | 'ESTIMATED'
+}
+export interface CalendarReturn {
+ containerBookingId: string
+ containerNo: string | null
+ fleetSupplierName: string | null
+ actualEmptyReturnAt: string | null
+ emptyReturnWarning: { status: string; deadlineAt: string | null }
+}
+export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
+export interface WarehouseArrival {
+ sourceContainerBookingId: string
+ cabinetNo?: string | null
+ containerNo: string | null
+ warehouseName: string | null
+ warehouseCode: string | null
+ plannedAt: string | null
+ actualAt: string | null
+ version: number
+ planCount: number
+ canEdit: boolean
+ plannedDate?: string | null
+ isDefault?: boolean
+}
+export interface ScheduleRange { from: string; to: string }
+type WarehouseArrivalWire = Omit & {
+ planCount: string | number
+ plannedAt?: string | null
+ actualAt?: string | null
+}
+
+/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
+function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
+ const count = row.planCount
+ if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
+ throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
+ }
+ const planCount = Number(count)
+ if (!Number.isSafeInteger(planCount) || planCount < 0) {
+ throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
+ }
+ return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
+}
+
+export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
+ return unwrapApiResponse(await http.get, ApiEnvelope>(
+ '/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
+ ))
+}
+export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
+ const rows = unwrapApiResponse(await http.post, ApiEnvelope>(
+ '/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
+ ))
+ return rows.map(normalizeWarehouseArrival)
+}
+export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
+export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
+ return unwrapApiResponse(await http.put, ApiEnvelope>(
+ `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
+ { headers: { 'Idempotency-Key': key } },
+ ))
+}
diff --git a/src/modules/appointment-calendar/temporal-picker.test-support.ts b/src/modules/appointment-calendar/temporal-picker.test-support.ts
new file mode 100644
index 00000000..1a2683ae
--- /dev/null
+++ b/src/modules/appointment-calendar/temporal-picker.test-support.ts
@@ -0,0 +1,8 @@
+const input = {
+ props: ['modelValue', 'disabled', 'type', 'format', 'valueFormat'],
+ emits: ['update:modelValue', 'change'],
+ template: ``,
+}
+
+export const DatePickerStub = { name: 'ElDatePicker', ...input }
+export const TimePickerStub = { name: 'ElTimePicker', ...input }
diff --git a/src/modules/appointment-calendar/temporalControls.test.ts b/src/modules/appointment-calendar/temporalControls.test.ts
new file mode 100644
index 00000000..fbbe7d1c
--- /dev/null
+++ b/src/modules/appointment-calendar/temporalControls.test.ts
@@ -0,0 +1,36 @@
+import { readdirSync, readFileSync } from 'node:fs'
+import { join, relative } from 'node:path'
+import { parse } from 'vue/compiler-sfc'
+import { expect, it } from 'vitest'
+
+/** 持续检查后台业务模板,避免再次用原生日期控件替代 Element 公共控件。 */
+it('uses Element date and time pickers throughout Admin business templates', () => {
+ const root = join(process.cwd(), 'src/modules')
+ const violations: string[] = []
+ function inspect(content: DocumentFragment, file: string) {
+ for (const input of content.querySelectorAll('input, el-input')) {
+ const type = input.getAttribute('type') || ''
+ if (/^(date|time|month|week|datetime-local)$/.test(type)) {
+ violations.push(`${relative(root, file)} ${input.tagName.toLowerCase()} type=${type}`)
+ }
+ }
+ // 条件和插槽模板的内容位于独立的 DocumentFragment 中。
+ for (const template of content.querySelectorAll('template')) inspect(template.content, file)
+ }
+ function visit(directory: string) {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const file = join(directory, entry.name)
+ if (entry.isDirectory()) visit(file)
+ else if (entry.name.endsWith('.vue')) {
+ const source = parse(readFileSync(file, 'utf8')).descriptor.template?.content
+ if (source) {
+ const template = document.createElement('template')
+ template.innerHTML = source
+ inspect(template.content, file)
+ }
+ }
+ }
+ }
+ visit(root)
+ expect(violations).toEqual([])
+})
diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts
index 13e8b1e3..4debfccd 100644
--- a/src/modules/appointment-calendar/tripCalendar.test.ts
+++ b/src/modules/appointment-calendar/tripCalendar.test.ts
@@ -3,10 +3,40 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend
import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
const range = { from: '2026-10-01', to: '2026-11-01' }
-function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
- return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
+function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
+ return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
+ it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
+ const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'] }
+ const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
+ expect(projected.events).toHaveLength(1)
+ expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
+ expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
+ expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜次号', value: 'D1821、D1822' })
+ expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
+ expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
+ const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
+ const parts = Object.fromEntries(beijing.formatToParts(new Date(row.plannedDepartureAt!)).map(part => [part.type, part.value]))
+ expect([parts.year, parts.month, parts.day, parts.hour, parts.minute]).toEqual(['2026', '10', '23', '00', '00'])
+ expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-22', to: '2026-10-23' }).events).toHaveLength(1)
+ expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-23', to: '2026-10-24' }).events).toEqual([])
+ })
+ it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
+ const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
+ const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
+ expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' })
+ const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range)
+ expect(missing.events).toEqual([])
+ expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN')
+ })
+ it('counts a shared trip once and excludes cancelled arrangements', () => {
+ const shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
+ const cancelled = { ...trip('DS-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
+ const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
+ expect(result.events).toHaveLength(1)
+ expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-SHARED 排车' })
+ })
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
@@ -18,23 +48,23 @@ describe('location-based trip calendar', () => {
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
- expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
+ expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']])
})
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
- expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
+ expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive'])
})
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
- const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
- const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
+ const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
+ const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
- expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
+ expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'})
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
})
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
- const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
+ const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
const november = {from:'2026-11-01',to:'2026-12-01'}
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
@@ -43,9 +73,9 @@ describe('location-based trip calendar', () => {
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
})
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
- const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
+ const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
expect(result.events).toEqual([])
- expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
+ expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID'])
})
})
diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts
index b28b8fee..09be7bfc 100644
--- a/src/modules/appointment-calendar/tripCalendar.ts
+++ b/src/modules/appointment-calendar/tripCalendar.ts
@@ -1,9 +1,11 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
+import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
- sourceType: 'TRIP_PLAN'
- tripNo: string
+ sourceType: 'DISPATCH'
+ dispatchNo: string
+ containerNumbers: string[]
+ cabinetNumbers: string[]
status: string
originWarehouseId: string | null
originWarehouseCode: string | null
@@ -23,6 +25,7 @@ export const tripIssueLabels: Record = {
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
+ DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
}
export function tripIssues(row: TripCalendarEntry) {
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
@@ -54,9 +57,16 @@ function localStamp(value: string | null, zone: string | null) {
} catch { return null }
}
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
- const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
+ const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
+ const seen = new Set()
for (const row of result.records) {
+ if (!row.dispatchNo?.trim()) {
+ unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue
+ }
+ if (seen.has(row.dispatchNo)) continue
+ seen.add(row.dispatchNo)
+ if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
if (row.originCountryCode !== 'US' || !occurrence) {
@@ -64,14 +74,18 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
}
if (occurrence.date < range.from || occurrence.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone)
+ const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
- events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
- title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
- sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
+ events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
+ title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '按起运仓当地计划发车时间展示',
+ sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
fields: [
- { label: '车次状态', value: labels[row.status] || '待核实' },
+ { label: '运输状态', value: labels[row.status] || '待核实' },
+ { label: '关联柜号', value: row.containerNumbers.join('、') },
+ { label: '关联柜次号', value: row.cabinetNumbers.join('、') },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone },
+ { label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
{ label: '路线', value: row.stops.slice(0, 3).map(stop => stop.name).join(' → ') + (row.stops.length > 3 ? ` 等${row.stops.length}站` : '') },
{ label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },
diff --git a/src/modules/appointment-calendar/useWorkSyncRead.test.ts b/src/modules/appointment-calendar/useWorkSyncRead.test.ts
new file mode 100644
index 00000000..be6875b2
--- /dev/null
+++ b/src/modules/appointment-calendar/useWorkSyncRead.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { ApiResponseError } from '@/shared/api/http'
+import { useWorkSyncRead } from './useWorkSyncRead'
+const pending = () => new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409, undefined, 'request-sync', 'trace-sync')
+afterEach(() => vi.useRealTimers())
+it('waits only for the full-work synchronization guard and returns the fresh result', async () => {
+ vi.useFakeTimers()
+ const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValueOnce(pending()).mockResolvedValue('fresh')
+ const result = reader.read(load, new AbortController().signal)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(reader.syncing.value).toBe(true)
+ expect(load).toHaveBeenCalledTimes(1)
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(await result).toBe('fresh')
+ expect(reader.syncing.value).toBe(false)
+})
+it.each([new ApiResponseError('事项版本已变化', 'A0409', 409), new ApiResponseError('无权查看', 'A0403', 403), new Error('network')])('does not retry other failures: %s', async error => {
+ const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
+ await expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
+ expect(load).toHaveBeenCalledTimes(1)
+ expect(reader.syncing.value).toBe(false)
+})
+it('stops waiting at the limit and preserves diagnostic IDs on the original error', async () => {
+ vi.useFakeTimers()
+ const error = pending(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
+ const result = expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
+ await vi.advanceTimersByTimeAsync(30000)
+ await result
+ expect(load).toHaveBeenCalledTimes(16)
+ expect(reader.syncing.value).toBe(false)
+ expect(error.traceId).toBe('trace-sync')
+ expect(vi.getTimerCount()).toBe(0)
+})
+it('cancels a waiting read without sending another request', async () => {
+ vi.useFakeTimers()
+ const controller = new AbortController(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(pending())
+ const result = expect(reader.read(load, controller.signal)).rejects.toHaveProperty('name', 'AbortError')
+ await vi.advanceTimersByTimeAsync(0)
+ controller.abort()
+ await result
+ await vi.advanceTimersByTimeAsync(30000)
+ expect(load).toHaveBeenCalledTimes(1)
+ expect(reader.syncing.value).toBe(false)
+ expect(vi.getTimerCount()).toBe(0)
+})
diff --git a/src/modules/appointment-calendar/useWorkSyncRead.ts b/src/modules/appointment-calendar/useWorkSyncRead.ts
new file mode 100644
index 00000000..62c1a74e
--- /dev/null
+++ b/src/modules/appointment-calendar/useWorkSyncRead.ts
@@ -0,0 +1,30 @@
+import { ref } from 'vue'
+import { getApiErrorDetails } from '@/shared/api/http'
+
+// 只等待工作清单同步守卫;版本冲突、权限失败和写请求均不重试。
+export function useWorkSyncRead() {
+ const syncing = ref(false)
+ let generation = 0
+ async function read(load: () => Promise, signal: AbortSignal): Promise {
+ const current = ++generation
+ syncing.value = false
+ try {
+ for (let attempt = 0; ; attempt++) {
+ signal.throwIfAborted()
+ try { return await load() }
+ catch (cause) {
+ const error = getApiErrorDetails(cause)
+ if (error.code !== 'A0409' || error.message !== '预约批次或运输关联已变化,服务端正在协调完整工作清单' || attempt >= 15) throw cause
+ if (current === generation) syncing.value = true
+ await new Promise((resolve, reject) => {
+ const abort = () => { clearTimeout(timer); reject(signal.reason) }
+ const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 2000)
+ signal.addEventListener('abort', abort, { once: true })
+ if (signal.aborted) { signal.removeEventListener('abort', abort); abort() }
+ })
+ }
+ }
+ } finally { if (current === generation) syncing.value = false }
+ }
+ return { syncing, read }
+}
diff --git a/src/modules/audit/AGENTS.md b/src/modules/audit/AGENTS.md
new file mode 100644
index 00000000..7a23b512
--- /dev/null
+++ b/src/modules/audit/AGENTS.md
@@ -0,0 +1,28 @@
+# Admin 操作日志与审计模块指引
+
+## 路径基准与读取顺序
+
+- 本文件适用于 `src/modules/audit/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
+- 本模块**没有独立页面**:审计日志是客户详情里的内嵌面板(`components/CustomerAuditLogsPanel.vue`),查询接口在 `api/platformAudit.ts`。
+- 后端查 `audit-service/`(`PlatformAuditLogController`、`AuditLogQueryService`);若是“操作人没被记录”,还要查采集侧 `common-audit/`(`BusinessAuditAspect.java`、`AuditEvent.java`)。
+
+## 页面与入口
+
+| 目标 | 入口 | API 与后端 |
+|---|---|---|
+| 客户详情内的操作日志面板、分页与详情 | `components/CustomerAuditLogsPanel.vue`(props:`customerId`、`customerName`) | `api/platformAudit.ts` 的 `listPlatformAuditLogs`、`getPlatformAuditLog`;`audit-service/` |
+| 日志字段展示、时间与文案映射 | `presentation.ts` | 服务端返回结构 |
+| 日志列表/详情的对外类型 | `index.ts` | `audit-service` DTO |
+| 操作人缺失、字段为空 | 面板空态与列定义 | 先查 `common-audit/` 是否采集该字段,再查 `audit-service/` 摄取 DTO 与表结构 |
+
+## 关键边界
+
+- 列表不返回变更前后快照,详情才返回已脱敏 JSON;不要在列表里展开完整 payload。
+- 客户账号不得访问平台审计查询;面板只在平台侧客户详情内使用。
+- 审计字段缺失属于“采集侧漏采”或“查询侧未映射”,两者分属 `common-audit/` 与 `audit-service/`,不要只改前端展示。
+- 审计链路故障不得阻塞核心交易,前端也不得据此推断业务失败。
+
+## 验证
+
+- 定向:`pnpm test src/modules/audit/<目标测试>`;字段或查询变化同时核对 `audit-service` 的查询接口与表结构。
+- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
diff --git a/src/modules/audit/components/CustomerAuditLogsPanel.test.ts b/src/modules/audit/components/CustomerAuditLogsPanel.test.ts
index 45c54c40..be3a960f 100644
--- a/src/modules/audit/components/CustomerAuditLogsPanel.test.ts
+++ b/src/modules/audit/components/CustomerAuditLogsPanel.test.ts
@@ -1,17 +1,22 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
-import { flushPromises, mount } from '@vue/test-utils'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
+import AppDrawer from '@/shared/components/AppDrawer.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
-import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
+import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail } from '../api/platformAudit'
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
+const detail: PlatformAuditLogDetail = { ...row, resourceId: '9007199254740993123', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' }
+
+enableAutoUnmount(afterEach)
+
function mountPanel(permissions: string[]) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
@@ -20,7 +25,7 @@ function mountPanel(permissions: string[]) {
}
describe('CustomerAuditLogsPanel', () => {
- beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
+ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getPlatformAuditLog).mockResolvedValue(detail); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
it('queries logs with the current customer id', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
@@ -91,17 +96,24 @@ describe('CustomerAuditLogsPanel', () => {
})
it('loads detail and formats json snapshots', async () => {
- vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
+ const privateId = '9007199254740993123'
+ vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, id: privateId }], page: 1, size: 20, total: 1, pages: 1 })
+ vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, id: privateId, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
- await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
- expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ expect(getPlatformAuditLog).toHaveBeenCalledWith(privateId)
expect(wrapper.text()).toContain('旧名称')
expect(wrapper.text()).toContain('新名称')
expect(wrapper.text()).toContain('入库测量审核通过')
expect(wrapper.text()).toContain('入库测量')
expect(wrapper.text()).toContain('头程履约服务')
expect(wrapper.text()).toContain('更新')
- expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
+ expect(wrapper.find('.audit-detail-table').exists()).toBe(true)
+ expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '操作日志详情 · 柏悦物流', size: 'min(920px, 96vw)', showFooter: false })
+ expect(wrapper.find('.audit-detail-table').text()).toContain('req')
+ expect(wrapper.find('.audit-detail-table').text()).toContain('trace')
+ expect(wrapper.find('.audit-detail-table').text()).not.toContain(privateId)
+ expect(wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').attributes('aria-label')).not.toContain(privateId)
})
it('loads the selected audit page', async () => {
@@ -131,4 +143,100 @@ describe('CustomerAuditLogsPanel', () => {
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
})
+ it('preserves failure details and complete long or non-json snapshots as text', async () => {
+ const longText = '长内容'.repeat(1200)
+ const raw = '\n不是JSON'
+ vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: '数据版本冲突', beforeData: JSON.stringify({ note: longText }), afterData: raw })
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ const drawer = wrapper.findComponent(AppDrawer)
+ expect(drawer.text()).toContain('操作失败')
+ expect(drawer.get('[role="alert"]').text()).toContain('数据版本冲突')
+ const snapshots = drawer.findAll('.density-code-block')
+ expect(snapshots[0].text()).toBe(JSON.stringify({ note: longText }, null, 2))
+ expect(snapshots[1].text()).toBe(raw)
+ expect(drawer.find('script').exists()).toBe(false)
+ })
+
+ it('keeps missing snapshots compact and a zero duration visible', async () => {
+ vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: null, errorCode: 'OPERATION_CONFLICT', durationMs: 0, beforeData: '', afterData: null, requestId: null, traceId: null })
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ expect(wrapper.findAll('.density-code-block').map(snapshot => snapshot.text())).toEqual(['无', '无'])
+ expect(wrapper.find('.audit-detail-table').text()).toContain('0 ms')
+ expect(wrapper.find('.audit-detail-table').text()).not.toContain('链路标识')
+ expect(wrapper.find('.audit-detail-table').text()).toContain('OPERATION_CONFLICT')
+ expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).toContain('操作失败,请查看诊断信息')
+ expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).not.toContain('OPERATION_CONFLICT')
+ })
+
+ it('retries the selected log after a detail request failure', async () => {
+ vi.mocked(getPlatformAuditLog).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(detail)
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ expect(wrapper.findComponent(AppDrawer).text()).toContain('详情加载失败')
+ await wrapper.findComponent(AppDrawer).get('.load-error-state button').trigger('click'); await flushPromises()
+ expect(vi.mocked(getPlatformAuditLog).mock.calls).toEqual([['1'], ['1']])
+ expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
+ expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
+ })
+
+ it.each(['resolve', 'reject'] as const)('ignores a late %s from the previously selected log', async outcome => {
+ let finish!: (value: PlatformAuditLogDetail) => void
+ let fail!: (reason: Error) => void
+ const previous = new Promise((resolve, reject) => { finish = resolve; fail = reject })
+ vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row, { ...row, id: '2', description: '第二条记录' }], page: 1, size: 20, total: 2, pages: 1 })
+ vi.mocked(getPlatformAuditLog).mockImplementation(id => id === '1' ? previous : Promise.resolve({ ...detail, id: '2', description: '第二条详情' }))
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ const actions = wrapper.findAll('.el-table__body button[aria-label^="查看操作日志:"]')
+ await actions[0].trigger('click'); await flushPromises()
+ await actions[1].trigger('click'); await flushPromises()
+ if (outcome === 'resolve') finish({ ...detail, description: '不应重新展示的旧详情' })
+ else fail(new Error('旧请求失败'))
+ await flushPromises()
+ expect(wrapper.findComponent(AppDrawer).props('loading')).toBe(false)
+ expect(wrapper.findComponent(AppDrawer).text()).toContain('第二条详情')
+ expect(wrapper.findComponent(AppDrawer).text()).not.toContain('不应重新展示的旧详情')
+ expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
+ })
+
+ it('closes and clears pending detail when switching customers', async () => {
+ let finish!: (value: PlatformAuditLogDetail) => void
+ vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ await wrapper.setProps({ customerId: '9', customerName: '另一客户' }); await flushPromises()
+ finish(detail); await flushPromises()
+ expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
+ expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
+ expect(listPlatformAuditLogs).toHaveBeenLastCalledWith(expect.objectContaining({ accountId: '9' }))
+ })
+
+ it('ignores pending responses after closing and permits a fresh reopen', async () => {
+ let finish!: (value: PlatformAuditLogDetail) => void
+ vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ wrapper.findComponent(AppDrawer).vm.$emit('update:modelValue', false)
+ await flushPromises()
+ finish({ ...detail, description: '过期详情' }); await flushPromises()
+ expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ expect(getPlatformAuditLog).toHaveBeenCalledTimes(2)
+ expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
+ expect(wrapper.findComponent(AppDrawer).text()).not.toContain('过期详情')
+ })
+
+ it('clears detail when audit permission is revoked', async () => {
+ const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
+ await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
+ useAuthStore().permissions = []
+ useAccessPreviewStore().initializeAfterLogin()
+ await flushPromises()
+ expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
+ expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
+ expect(wrapper.text()).toContain('暂无操作日志查看权限')
+ expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
+ })
+
})
diff --git a/src/modules/audit/components/CustomerAuditLogsPanel.vue b/src/modules/audit/components/CustomerAuditLogsPanel.vue
index e17c1d43..64b0c3b2 100644
--- a/src/modules/audit/components/CustomerAuditLogsPanel.vue
+++ b/src/modules/audit/components/CustomerAuditLogsPanel.vue
@@ -1,7 +1,8 @@
-
- 操作日志
{{ customerName }}已归属当前客户的档案与业务操作记录
+
+
暂无操作日志查看权限请联系管理员开通平台操作日志查看权限。
-
+
-
- 详情
+
+ 详情
{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}
{{ display((row as PlatformAuditLogListItem).clientIp) }}
- {{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}
+ {{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}
{{ auditOperatorLabel((row as PlatformAuditLogListItem).username) }}
{{ auditServiceLabel((row as PlatformAuditLogListItem).serviceName) }}
{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}
{{ formatDate((row as PlatformAuditLogListItem).occurredAt) }}
共 {{ total }} 条客户操作记录
-
+
-
-
-
-
-
- 事件类型{{ auditEventLabel(detail.eventType) }}
{{ auditDescriptionLabel(detail.description) }}
- {{ detail.success ? '操作成功' : '操作失败' }}
+
+
+
+
+ {{ auditEventLabel(detail.eventType) }} {{ detail.success ? '操作成功' : '操作失败' }}
+ {{ auditDescriptionLabel(detail.description) }}
-
-
- 操作人{{ auditOperatorLabel(detail.username) }}
- 发生时间{{ formatDate(detail.occurredAt) }}
- 来源服务{{ auditServiceLabel(detail.serviceName) }}{{ display(detail.serviceName) }}
- 客户端 IP{{ display(detail.clientIp) }}
- 业务资源{{ auditResourceLabel(detail.resourceType) }}{{ display(detail.resourceId) }}
- 处理耗时{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}
- 请求地址{{ auditHttpMethodLabel(detail.httpMethod) }}{{ display(detail.requestUri) }}
- 请求标识{{ display(detail.requestId) }}链路标识:{{ detail.traceId }}
+
+ 操作信息
+
+ | 操作人 | {{ auditOperatorLabel(detail.username) }} | 发生时间 | {{ formatDate(detail.occurredAt) }} |
+ | 来源服务 | {{ auditServiceLabel(detail.serviceName) }}{{ display(detail.serviceName) }} | 客户端 IP | {{ display(detail.clientIp) }} |
+ | 业务资源 | {{ auditResourceLabel(detail.resourceType) }} | 处理耗时 | {{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }} |
+ | 请求地址 | {{ auditHttpMethodLabel(detail.httpMethod) }}{{ display(detail.requestUri) }} |
+ | 请求标识 | {{ display(detail.requestId) }} |
+ | 错误码(诊断) | {{ detail.errorCode }} |
+ | 链路标识 | {{ detail.traceId }} |
+
-
-
- 操作前快照
操作执行前保留的业务数据
{{ formatSnapshot(detail.beforeData) }}操作结果快照
操作执行后返回或保存的业务数据
{{ formatSnapshot(detail.afterData) }}
-
+
+
+
操作前快照
{{ formatSnapshot(detail.beforeData) }}
+
操作结果快照
{{ formatSnapshot(detail.afterData) }}
+
+
@@ -176,10 +210,15 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
-.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
-.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; max-width:100%; padding:3px 9px; overflow:hidden; border:1px solid #dbeafe; border-radius:999px; background:#eff6ff; color:#1d4ed8; font-size:var(--admin-font-caption, 12px); line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:#475467; text-overflow:ellipsis; white-space:nowrap; }
-.audit-detail-hero { display:flex; align-items:flex-start; gap:13px; padding:var(--admin-space-3, 8px) var(--admin-space-4, 10px); border:1px solid #d1fadf; border-radius:0; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; height:var(--admin-tab-height, 38px); flex:0 0 auto; place-items:center; border-radius:50%; background:#dcfae6; color:#079455; }.is-failed .result-icon { background:#fee4e2; color:#d92d20; }.hero-copy { min-width:0; flex:1; }.hero-copy > span { color:#667085; font-size:var(--admin-font-caption, 12px); }.hero-copy h3 { margin:3px 0 var(--admin-space-1, 4px); color:#101828; font-size:var(--admin-font-section, 14px); }.hero-copy p { margin:0; color:#475467; font-size:var(--admin-font-body, 13px); line-height:1.55; }
-.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:var(--admin-space-6, 16px); overflow:hidden; border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.audit-detail-grid > div { display:flex; min-width:0; min-height:var(--admin-row-height, 36px); flex-direction:column; justify-content:center; gap:5px; padding:var(--admin-space-2, 6px) var(--admin-space-4, 10px); border-right:1px solid #eaecf0; border-bottom:1px solid #eaecf0; }.audit-detail-grid > div:nth-child(3n) { border-right:0; }.audit-detail-grid > div.wide { grid-column:1/-1; border-right:0; }.audit-detail-grid > div:last-child { border-bottom:0; }.audit-detail-grid span { color:#667085; font-size:var(--admin-font-caption, 12px); }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:var(--admin-font-section, 14px); font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.request-cell > div { display:flex; min-width:0; align-items:center; gap:9px; }.request-cell code { min-width:0; overflow-wrap:anywhere; color:#344054; font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }.failure-alert { margin-top:var(--admin-space-6, 16px); }
-.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-6, 16px); margin-top:18px; }.snapshot-grid section { min-width:0; padding:var(--admin-space-4, 10px); border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.snapshot-grid h3 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.snapshot-grid p { margin:var(--admin-space-1, 4px) 0 var(--admin-space-4, 10px); color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:var(--admin-label-bg, #f8fafc); color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
-@media(max-width:720px){.audit-detail-grid{grid-template-columns:1fr}.audit-detail-grid>div,.audit-detail-grid>div:nth-child(3n){border-right:0}.audit-detail-grid>div.wide{grid-column:auto}.snapshot-grid{grid-template-columns:1fr}.audit-detail-hero{align-items:flex-start}.audit-detail-hero>.el-tag{flex:0 0 auto}}
+.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--workbench-border); }.panel-title h2 { margin:0; color:var(--workbench-text); font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--workbench-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:var(--workbench-muted); text-align:center; }.module-empty strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
+.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; overflow:hidden; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:var(--workbench-text); text-overflow:ellipsis; white-space:nowrap; }
+.audit-event-description { margin:0; overflow-wrap:anywhere; }
+.audit-detail-table { table-layout:fixed; }
+.audit-detail-table th { width:92px; }
+.audit-service-code { display:block; }
+.audit-request { display:flex; align-items:center; gap:var(--admin-space-3); }
+.audit-request code { min-width:0; }
+.audit-snapshots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
+.audit-snapshots > .density-dialog-section { margin-bottom:0; }
+@media(max-width:720px) { .audit-snapshots { grid-template-columns:1fr; } }
diff --git a/src/modules/auth/AGENTS.md b/src/modules/auth/AGENTS.md
new file mode 100644
index 00000000..d4a0382b
--- /dev/null
+++ b/src/modules/auth/AGENTS.md
@@ -0,0 +1,27 @@
+# Admin 登录模块指引
+
+## 路径基准与读取顺序
+
+- 本文件适用于 `src/modules/auth/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
+- 模块只有一个页面 `pages/LoginView.vue`;认证状态、Token 刷新与路由守卫在 `../../core/auth/` 与 `../../shared/api/AGENTS.md`,本模块不复制这些逻辑。
+- 后端只查 `auth/`(登录、刷新、退出、改密)。
+
+## 页面与入口
+
+| 目标 | 页面/入口 | API 与后端 |
+|---|---|---|
+| 平台员工登录页、表单校验与错误提示 | `pages/LoginView.vue` | `../../core/auth/auth.ts` → `../../shared/api/http.ts`;`auth/` |
+| 登录态与路由跳转 | 页面不持有状态 | `../../core/auth/auth.ts`、`../../core/access/` |
+| 401 刷新与重放 | 页面不处理 | `../../shared/api/http.ts`、`../../shared/api/AGENTS.md` |
+
+## 关键边界
+
+- 页面不读写 Token、不拼 `Authorization`、不自行持久化密码;令牌与刷新编排只在 `src/core/auth/` 与 `src/shared/api/http.ts`。
+- 认证失败提示按服务端返回的 `code` 与文案展示,不把 401/403 混为同一种提示。
+- 登录成功后跳转由路由守卫决定,页面不硬编码默认落地页。
+- 记住账号类能力只保存登录标识,不持久化密码或 Token。
+
+## 验证
+
+- 定向:`pnpm test src/modules/auth/<目标测试>`;登录或守卫变化同时核对 `src/core/auth/` 与 `src/shared/api/http.ts`。
+- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
diff --git a/src/modules/auth/operation-matrix.md b/src/modules/auth/operation-matrix.md
new file mode 100644
index 00000000..602b6127
--- /dev/null
+++ b/src/modules/auth/operation-matrix.md
@@ -0,0 +1,14 @@
+# 平台登录页逐操作矩阵
+
+依据:2026-10-20 用户确认全 Admin 推广清爽蓝白风格;视觉参考为工作区 `docs/界面参考/蓝白卡片式工作台风格参考.md` 与其中的用户参考图。当前页面入口为 `pages/LoginView.vue`;本轮只调整展示,不改变认证状态、接口和路由跳转。
+
+| 页面 | 操作 | 当前承载与依据 | 本轮结论与验证 |
+| --- | --- | --- | --- |
+| 平台员工登录 | 输入账号、密码与提交 | `LoginView.vue` 的 `el-form` 与 `submit`;独立登录页,品牌区与表单左右布局 | 接入公共蓝白面板、图标和主题;保留原 `large` 表单尺寸、账号密码字段、自动完成、密码显隐与回车提交,不新增文案或字段 |
+| 平台员工登录 | 必填校验与加载 | 原 `rules`、`loading` 与表单标签 | 原必填提示及提交加载保持;不增加验证码或改变认证步骤 |
+| 平台员工登录 | 登录失败 | 原 `loginError`、`ApiErrorMeta` 与 `role="alert"` | 错误区域复用公共提示外观,服务端错误文案、诊断信息和可恢复表单保持 |
+| 平台员工登录 | 登录成功后跳转 | 原 `auth.login`、`initializeAfterLogin` 与 `sanitizeInternalPath` | 成功提示、安全跳转、首个授权入口及无权限回退均保持,不修改 router/store |
+| 平台员工登录 | 已有窄屏品牌布局 | 原 `960px`、`360px` 响应式规则 | 保留既有窄屏能力,不新增多视口验收范围 |
+| 平台员工登录 | 详情、新增、编辑、审核、文件预览、删除 | 当前页面无上述操作 | 不新增操作或浮层 |
+
+自动回归入口:`pages/LoginView.test.ts`。本模块未独立执行检查,统一由本轮总控完成。视觉待人工 `1920×1080`、浏览器缩放 `100%` 截图确认。
diff --git a/src/modules/auth/pages/LoginView.test.ts b/src/modules/auth/pages/LoginView.test.ts
index 755736f1..de5f20b6 100644
--- a/src/modules/auth/pages/LoginView.test.ts
+++ b/src/modules/auth/pages/LoginView.test.ts
@@ -43,14 +43,18 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip')
})
- it('uses the admin design tokens and keeps the brand visible at 320px', () => {
- expect(loginSource).toContain('柏悦物流管理后台
')
- expect(loginSource).toContain('class="login-mobile-brand"')
+ it('uses the shared blue-white theme and preserves the existing compact brand layout', () => {
+ expect(loginSource).toContain("import '@/styles/adminWorkbench.css'")
+ expect(loginSource).toContain('class="login-page admin-workbench"')
+ expect(loginSource).toContain('柏悦物流管理后台
')
+ expect(loginSource).toContain('class="login-mobile-brand workbench-brand-mark"')
+ expect(loginSource).toContain('class="login-card workbench-panel"')
+ expect(loginSource).toContain('class="login-error workbench-notice workbench-tone" data-tone="rose"')
expect(loginSource).toContain('aria-label="平台员工登录"')
+ expect(loginSource).toContain('label-position="top" size="large"')
expect(loginSource).toContain('autocomplete="username" maxlength="100" autofocus')
expect(loginSource).toContain('aria-live="polite"')
- expect(loginSource).toContain('background: var(--cc-surface-muted)')
- expect(loginSource).toContain('border-radius: var(--cc-radius-lg)')
+ expect(loginSource).not.toContain('var(--cc-navy)')
expect(loginSource).toContain('@media (max-width: 360px)')
expect(loginSource).not.toContain('让跨境物流管理')
})
diff --git a/src/modules/auth/pages/LoginView.vue b/src/modules/auth/pages/LoginView.vue
index a520e614..83c47277 100644
--- a/src/modules/auth/pages/LoginView.vue
+++ b/src/modules/auth/pages/LoginView.vue
@@ -9,6 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
+import '@/styles/adminWorkbench.css'
const route = useRoute()
const router = useRouter()
@@ -54,25 +55,25 @@ async function submit() {
-
+
- ![柏悦物流]()
-
-
柏悦物流管理后台
-
平台内部运营与管理系统
+
+
-
-
+
+
平台内部系统,请使用授权账号登录
-
-
-
-
登录管理后台
-
使用平台内部员工账号登录
+
+
+
+
登录管理后台
+
使用平台内部员工账号登录
@@ -84,38 +85,43 @@ async function submit() {
登录
-
+
diff --git a/src/modules/container-loading/AGENTS.md b/src/modules/container-loading/AGENTS.md
new file mode 100644
index 00000000..5a625ee0
--- /dev/null
+++ b/src/modules/container-loading/AGENTS.md
@@ -0,0 +1,37 @@
+# Admin 货柜模块指引
+
+## 路径基准与读取顺序
+
+- 本文件适用于 `src/modules/container-loading/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面/接口细则见 `../../../docs/business-index.md`。
+- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;接口、状态、金额或权限变化时再读详细索引命中行。
+- 后端只查直接消费方:`firstleg/`(`loading/`、`customs/container/`、`terminaldrayage/`、`oceanbooking/`)、`lastleg-service/`(柜级到仓与尾程)、`file/`、`print/`。纯页面与文案不读后端。
+
+## 页面与入口
+
+| 目标 | 页面/路由 | API 与后端 |
+|---|---|---|
+| 国内装柜列表、模拟柜、订舱关联 | `pages/ContainersView.vue`、`pages/DomesticContainersView.vue`;`container-loading/domestic` | `api/platformContainerLoading.ts`;`components/ContainerSimulationCreate.vue`、`components/OceanBookingLinkDialog.vue`;`firstleg/loading/` |
+| 送柜报关详情、装柜清单页签、报关、出仓 | `pages/DomesticContainerDetailView.vue`、共享 `pages/ContainerDetailView.vue`;`container-loading/domestic/:containerId` | `components/ContainerOverviewPanel.vue`、`components/ContainerCustomsPanel.vue`;`api/platformContainerCustoms.ts`;`firstleg/customs/container/`、`file/` |
+| 智能装柜工作台、柜次容量与分配 | `pages/SmartLoadingWorkbenchView.vue`;`container-loading/domestic/:containerId/smart-loading` | `api/platformContainerLoading.ts`;`../unified-orders/workflows/components/SmartLoadingWorkbench.vue`;`firstleg/service/SmartLoadingService.java` |
+| 全局智能装柜弹框、入仓码候选与箱数 | `pages/GlobalSmartLoadingView.vue`(内嵌,无独立路由)、`components/SmartLoadingDialog.vue`、`components/SmartLoadingCandidateInbounds.vue` | `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`smartLoadingPlanGroups.ts`;后端 `/inbound-candidates`、`/containers`、`/inbound-assignments` |
+| 海外清关提柜工作台、船节点、清关与 DO/PU | `pages/OverseasContainersView.vue`;`container-loading/overseas` | `api/platformContainerLoading.ts`;`firstleg/` 船节点与 `customs/`;`components/ContainerOverviewPanel.vue` |
+| 目的港码头拖车、车队与还空 | `pages/OverseasContainerDetailView.vue`(薄包装)、共享 `pages/ContainerDetailView.vue`(实际内容);`container-loading/overseas/:containerId` | `api/terminalDrayages.ts` 由共享详情调用;`firstleg/terminaldrayage/`(`TerminalDrayageService.java`、`DirectDeliveryService.java`) |
+| 整柜预计/实际到仓 | 列表与详情“到仓”列 | `api/containerArrivalTimes.ts`;`lastleg-service/` 柜级订单 |
+| 装柜照片与证据的展示/读取(上传在 PDA 与 Firstleg) | `components/ContainerOverviewPanel.vue` | 证据读取走 `file/`;生产与上传见 `firstleg/loading/service/CabinetLoadingEvidenceService.java` 与国内 PDA |
+| 海外柜费用 | `components/ContainerLastLegFeesPanel.vue` | `api/overseasContainerFees.ts`;`finance/`、`firstleg/` 费用来源 |
+
+`pages/ContainerWorkbenchView.vue` 是工作台外壳,按权限在“国内装柜”“清关提柜”间切换;`pages/ContainersView.vue` 与 `ContainerDetailView.vue` 为共享实现,改两份前先确认是否同源。
+
+## 关键边界
+
+- 列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,写资格只取服务端 `allowedActions`;不用浏览器筛选、本地推导或柜内明细数量代替。
+- 创建柜次时自动生成唯一预装计划;订舱关联允许真实集装箱号为空,但**确认预装和创建 PDA 任务前必须补齐**,历史上已建缺号任务可在预装详情首次补录。
+- 还空截止、超时天数只展示服务端按权威卸柜完成时间加 72 小时计算的结果,前端不重算、不用拖车状态当阻断门禁。
+- 国内“送柜报关”只消费 `/containers/domestic`,海外“清关提柜”只消费 `/containers/overseas`;两端统一用 `containerBookingId`,禁止混用两套列表接口。
+- 智能装柜候选状态只作筛选展示,按钮资格以服务端 `canAssign` 为准;计划箱数只消费 `totalPackageCount/remainingPackageCount`,不从候选页或本地规格重算。
+- 前端不计算托数、不逐票建计划、不预占托盘;柜级转换必须原样回传服务端 `snapshotVersion/snapshotHash`,冲突后重新预览。
+
+## 验证
+
+- 定向:`pnpm test src/modules/container-loading/<目标测试>`;API 或类型变化同时运行 `pnpm typecheck`。
+- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`;离线检查不代替真实 HTTP 与人工截图验收。
diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts
index e379e18e..4983df80 100644
--- a/src/modules/container-loading/api/containerArrivalTimes.test.ts
+++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries visible containers and updates the whole-container expected arrival', async () => {
- vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
- vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
+ vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
await listContainerArrivalTimes(['9001'])
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
@@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
})
+
+ it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => {
+ const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] })
+ expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }])
+ for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) {
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] })
+ await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效')
+ }
+ })
})
diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts
index aedc9f82..53b26fab 100644
--- a/src/modules/container-loading/api/containerArrivalTimes.ts
+++ b/src/modules/container-loading/api/containerArrivalTimes.ts
@@ -6,6 +6,16 @@ export interface ContainerArrivalTime {
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
version: number
+ planCount: number
+ plannedDate?: string | null
+ isDefault?: boolean
+}
+
+function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
+ if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
+ const count = Number(item.planCount)
+ if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
+ return { ...item, version: Number(item.version), planCount: count }
}
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
@@ -20,7 +30,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
queryPath,
{ sourceContainerBookingIds },
)
- return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
+ return (unwrapApiResponse(response) || []).map(normalizeArrival)
}
export async function updateContainerExpectedArrival(
@@ -33,5 +43,5 @@ export async function updateContainerExpectedArrival(
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
)
const result = unwrapApiResponse(response)
- return { ...result, version: Number(result.version || 0) }
+ return normalizeArrival(result)
}
diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts
index 70397885..9538db6a 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.test.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { http } from '@/shared/api/http'
-import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
-import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
+import { ApiResponseError, http } from '@/shared/api/http'
+import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
+import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, downloadContainerCustomsFinal, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
@@ -15,6 +15,22 @@ const customs = {
describe('platform container customs API', () => {
beforeEach(() => vi.restoreAllMocks())
+ it('preserves the server batch number when creating and submitting a declaration', async () => {
+ const batch = { id: '123', customsContainerId: '456', loadingPlanId: '789', batchNo: 'DECL202610200001', sourceVersion: 1, version: 0, results: [] }
+ const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: batch })
+ const created = await createDeclarationBatch('789', 3)
+ expect(created.batchNo).toBe('DECL202610200001')
+ expect(post.mock.calls[0]?.slice(0, 2)).toEqual([
+ '/platform/v1/customs/loading-plans/789/declaration-batches', { expectedVersion: 3, remark: null },
+ ])
+ const submitted = await submitDeclarationBatch(created, ' CUSTOMS-EXTERNAL-1 ')
+ expect(submitted.batchNo).toBe(created.batchNo)
+ expect(post.mock.calls[1]?.slice(0, 2)).toEqual([
+ '/platform/v1/customs/loading-plans/declaration-batches/123/submit',
+ { expectedVersion: 0, customsEntryNo: 'CUSTOMS-EXTERNAL-1', remark: null },
+ ])
+ })
+
it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => {
vi.useFakeTimers()
try {
@@ -83,7 +99,42 @@ describe('platform container customs API', () => {
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
- [`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
+ [`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
])
})
+
+ it.each([downloadContainerCustomsDocument, downloadContainerCustomsReceipt, downloadContainerCustomsFinal])('拒绝 HTTP 200 中的真实 JSON Blob 业务错误 %#', async (download) => {
+ const body = new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })], { type: 'application/json;charset=UTF-8' })
+ vi.spyOn(http, 'get').mockResolvedValue(body)
+
+ const error: unknown = await download('2092438843630391407').catch((reason: unknown) => reason)
+ expect(error).toBeInstanceOf(ApiResponseError)
+ expect(error).toMatchObject({ code: 'A0404', message: '预录单不存在', status: 200, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })
+ })
+
+ it('非 2xx 的 JSON Blob 也保留业务错误和诊断信息', async () => {
+ const body = new Blob([JSON.stringify({ code: 'A0403', message: '无权下载预录单', data: null, requestId: 'request-forbidden', traceId: 'trace-forbidden' })], { type: 'application/json' })
+ vi.spyOn(http, 'get').mockRejectedValue({ isAxiosError: true, response: { status: 403, data: body } })
+
+ await expect(downloadContainerCustomsReceipt('7')).rejects.toMatchObject({ code: 'A0403', message: '无权下载预录单', status: 403, requestId: 'request-forbidden', traceId: 'trace-forbidden' })
+ })
+
+ it.each([
+ ['application/pdf', '%PDF-1.7 原文件'],
+ ['application/octet-stream', '原始二进制'],
+ ['application/json', '{"product":"报关资料","code":"ITEM-1"}'],
+ ['application/json', '{invalid-json'],
+ ])('保留原文件内容与对象:%s', async (type, contents) => {
+ const body = new Blob([contents], { type })
+ vi.spyOn(http, 'get').mockResolvedValue(body)
+
+ await expect(downloadContainerCustomsFinal('7')).resolves.toBe(body)
+ })
+
+ it('非 JSON 的传输错误沿用原始错误', async () => {
+ const error = { isAxiosError: true, response: { status: 502, data: new Blob(['bad gateway'], { type: 'text/html' }) } }
+ vi.spyOn(http, 'get').mockRejectedValue(error)
+
+ await expect(downloadContainerCustomsReceipt('7')).rejects.toBe(error)
+ })
})
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index db2c3cd0..43279d19 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -1,4 +1,5 @@
-import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import axios from 'axios'
+import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
@@ -178,13 +179,13 @@ export async function getDeclarationBatches(loadingPlanId: string) {
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
}
-export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
- const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
+export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
- const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
+ const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
@@ -210,16 +211,45 @@ export async function getContainerCustoms(loadingPlanId: string) {
return value ? normalize(value) : null
}
+// 报关资料允许下载原始 JSON 文件,仅把统一业务错误信封识别为失败。
+async function customsDownloadError(blob: Blob, status: number): Promise {
+ if (!blob.type.toLowerCase().includes('json')) return null
+ let payload: unknown
+ try { payload = JSON.parse(await blob.text()) } catch { return null }
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
+ const envelope = payload as Record
+ if (typeof envelope.code !== 'string' || envelope.code === '200'
+ || typeof envelope.message !== 'string' || !('data' in envelope)) return null
+ return new ApiResponseError(envelope.message, envelope.code, status, envelope.data,
+ typeof envelope.requestId === 'string' ? envelope.requestId : undefined,
+ typeof envelope.traceId === 'string' ? envelope.traceId : undefined)
+}
+
+async function downloadCustomsFile(path: string) {
+ try {
+ const blob = await http.get(path, { responseType: 'blob' })
+ const error = await customsDownloadError(blob, 200)
+ if (error) throw error
+ return blob
+ } catch (error) {
+ if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
+ const downloadError = await customsDownloadError(error.response.data, error.response.status)
+ if (downloadError) throw downloadError
+ }
+ throw error
+ }
+}
+
export function downloadContainerCustomsDocument(documentId: string) {
- return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
+ return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/download`)
}
export function downloadContainerCustomsReceipt(documentId: string) {
- return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
+ return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`)
}
export function downloadContainerCustomsFinal(documentId: string) {
- return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
+ return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`)
}
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
index 6f2c83a2..23b30c49 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
-import ElementPlus from 'element-plus'
-import { ApiResponseError } from '@/shared/api/http'
+import ElementPlus, { ElMessageBox } from 'element-plus'
+import AppDialog from '@/shared/components/AppDialog.vue'
+import { ApiResponseError, http } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
const api = vi.hoisted(() => ({
@@ -14,6 +15,7 @@ const api = vi.hoisted(() => ({
downloadSource: vi.fn(),
downloadReceipt: vi.fn(),
downloadFinal: vi.fn(),
+ recordResult: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
@@ -27,6 +29,7 @@ vi.mock('../api/platformContainerCustoms', () => ({
submitContainerCustomsToBroker: vi.fn(),
sendContainerCustomsToBroker: api.sendToBroker,
getDeclarationBatches: api.getDeclarationBatches,
+ recordClearanceResult: api.recordResult,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -77,6 +80,53 @@ describe('ContainerCustomsPanel', () => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
+ it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => {
+ const batch: DeclarationBatch = {
+ id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
+ batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3,
+ customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null,
+ submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [],
+ }
+ api.getDeclarationBatches.mockResolvedValue([batch])
+ api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' })
+ const wrapper = mount(ContainerCustomsPanel, {
+ props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
+ global: { plugins: [ElementPlus] },
+ })
+ await flushPromises()
+ expect(wrapper.text()).toContain('ENTRY-001')
+ expect(wrapper.text()).not.toContain('版本')
+ await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
+ const state = (wrapper.vm.$ as unknown as { setupState: {
+ clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
+ } }).setupState
+ state.clearanceResultType = 'OTHER'
+ await flushPromises()
+ const dialog = wrapper.getComponent(AppDialog)
+ expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001')
+ expect(dialog.props('lockWhileSubmitting')).toBe(false)
+ dialog.vm.$emit('confirm')
+ await flushPromises()
+ expect(api.recordResult).not.toHaveBeenCalled()
+ state.clearanceRemark = ' 已核实海关通知 '
+ dialog.vm.$emit('confirm')
+ await flushPromises()
+ expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知')
+ expect(dialog.props('modelValue')).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => {
+ const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
+ const wrapper = await render('READY_TO_START')
+ await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
+ await flushPromises()
+ expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
+ expect(api.start).not.toHaveBeenCalled()
+ confirm.mockRestore()
+ wrapper.unmount()
+ })
+
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
const reasons: Array<[ClearanceResultType, string]> = [
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
@@ -116,15 +166,14 @@ describe('ContainerCustomsPanel', () => {
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
- expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
- expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
+ expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
- expect(wrapper.text()).toContain('报关行回执')
+ expect(wrapper.text()).toContain('预录单')
expect(wrapper.text()).not.toContain('客户报关资料')
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
- expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
- expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
+ expect(buttonTexts(wrapper)).not.toContain('上传预录单')
+ expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -157,13 +206,13 @@ describe('ContainerCustomsPanel', () => {
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('首次发送报关行')
- expect(wrapper.text()).toContain('首次发送后上传回执')
- expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
+ expect(wrapper.text()).toContain('首次发送后上传预录单')
+ expect(buttonTexts(wrapper)).not.toContain('上传预录单')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('发送报关行执行')
- expect(wrapper.text()).toContain('覆盖报关行回执')
+ expect(wrapper.text()).toContain('覆盖预录单')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
@@ -171,8 +220,8 @@ describe('ContainerCustomsPanel', () => {
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
- expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
- expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
+ expect(buttonTexts(wrapper)).not.toContain('上传预录单')
+ expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -188,14 +237,40 @@ describe('ContainerCustomsPanel', () => {
wrapper.unmount()
})
+ it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => {
+ const value = customs('DECLARING', true)
+ const document = value.documents[0]!
+ document.receipt!.version = 7
+ document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }]
+ api.get.mockResolvedValue(value)
+ const wrapper = mount(ContainerCustomsPanel, {
+ props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
+ global: { plugins: [ElementPlus] },
+ })
+ await flushPromises()
+ expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '客户退税资料', '预录单', '真实报关完税文件', '关联入仓码', '报关行状态', '客户', '预录单情况'])
+ expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
+ expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员')
+ expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
+ expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true)
+ const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
+ const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
+ onChange({ raw: selected })
+ await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click')
+ await flushPromises()
+ expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
+ wrapper.unmount()
+ })
+
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
- expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
- expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
+ expect(buttonTexts(wrapper)).not.toContain('上传预录单')
+ expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -206,7 +281,7 @@ describe('ContainerCustomsPanel', () => {
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadReceipt).not.toHaveBeenCalled()
- await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
+ await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
@@ -230,4 +305,46 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadReceipt).not.toHaveBeenCalled()
})
+
+ it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => {
+ const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' }))
+ api.downloadReceipt.mockImplementationOnce(async (documentId: string) => {
+ const actual = await vi.importActual('../api/platformContainerCustoms')
+ return actual.downloadContainerCustomsReceipt(documentId)
+ })
+ const wrapper = await render('COMPLETED', false, true)
+
+ await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
+ await flushPromises()
+
+ await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在'))
+ expect(wrapper.text()).toContain('request-pre-entry')
+ expect(wrapper.text()).toContain('trace-pre-entry')
+ expect(wrapper.text()).toContain('报关行回执.pdf')
+ expect(wrapper.text()).not.toContain('报关行回执不存在')
+ expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled()
+ expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' })
+ get.mockRestore()
+ wrapper.unmount()
+ })
+
+ it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => {
+ const message = '客户确认的预录单已发送给报关行,不可再修改预录单'
+ api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload'))
+ const wrapper = await render('DECLARING', true, true)
+ const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' })
+ const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
+ onChange({ raw: selected })
+ await flushPromises()
+ const submit = wrapper.findAll('button').find(button => button.text() === '提交')!
+ await submit.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain(message)
+ expect(wrapper.text()).toContain('A0409')
+ expect(wrapper.text()).toContain('request-pre-entry-upload')
+ expect(wrapper.text()).toContain('trace-pre-entry-upload')
+ expect(submit.attributes('disabled')).toBeUndefined()
+ wrapper.unmount()
+ })
})
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 65ea7b45..58905c02 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -1,5 +1,6 @@
-