From a4cb6c37579201e9aee007491bbb44d0f7965c21 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 18:51:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=8E=92=E8=BD=A6=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=8F=91=E5=B8=83=E6=95=A3=E6=9D=BF=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E5=B9=B6=E7=9B=B4=E6=8E=A5=E7=A1=AE=E8=AE=A4=E8=A3=85=E8=BD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-master-data/AGENTS.md | 4 +- .../overseas-master-data/PROTOTYPE_MATRIX.md | 2 +- .../api/dispatchCapacityDemands.test.ts | 15 ++++++ .../api/dispatchCapacityDemands.ts | 23 +++++++++ .../pages/DispatchCapacityDemandsView.test.ts | 40 ++++++++++++++++ .../pages/DispatchCapacityDemandsView.vue | 48 +++++++++++++++++++ src/modules/overseas-master-data/routes.ts | 2 +- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 10 ++++ .../overseas-warehouse/api/dispatches.test.ts | 10 ++-- .../DispatchLoadingDocuments.test.ts | 21 ++++++++ .../components/DispatchLoadingDocuments.vue | 2 +- .../DispatchWorkflowDialog.layout.test.ts | 10 ++-- .../components/DispatchWorkflowDialog.test.ts | 21 ++++++-- .../components/DispatchWorkflowDialog.vue | 13 ++--- .../components/SmartDispatchDialog.test.ts | 12 +++++ .../components/SmartDispatchDialog.vue | 7 +-- .../overseas-warehouse/dispatchContracts.ts | 10 ++-- .../dispatchTestFixtures.ts | 2 +- 18 files changed, 216 insertions(+), 36 deletions(-) create mode 100644 src/modules/overseas-master-data/api/dispatchCapacityDemands.test.ts create mode 100644 src/modules/overseas-master-data/api/dispatchCapacityDemands.ts create mode 100644 src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.test.ts create mode 100644 src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.vue diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index a5ddae79..a65135a6 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -5,7 +5,9 @@ - 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。 -- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。 +- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项在排车新建/维护车辆中默认勾选,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。 + +- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。 - 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 8801a799..23da3024 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -6,7 +6,7 @@ | 页面/操作 | 当前承载与调整 | 验收 | |---|---|---| -| 散板需求列表/查询、分页、详情 | 保持独立列表与原余位来源,默认展示车次余位、线路、时间、联系信息;隐藏待匹配散板、我的登记、登记、待办及“选择车次发布余位”入口,原代码保留 | 前轮43项定向测试及类型检查通过;本次旧入口修复35项定向测试通过;待人工1920×1080、100%截图确认 | +| 散板需求列表/查询、分页 | 同一正式路由改读当前派送排车摘要,按排车号展示起运仓、路线、计划发车、车辆容量、已排实托及真实/待确认余位;无登记和旧车次入口,原实现保留 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 | | 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;车次号改为纯文本,不能进入旧车次详情;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 | ## 2026-10-20 列表顶部紧凑收尾 diff --git a/src/modules/overseas-master-data/api/dispatchCapacityDemands.test.ts b/src/modules/overseas-master-data/api/dispatchCapacityDemands.test.ts new file mode 100644 index 00000000..ebe6d7c9 --- /dev/null +++ b/src/modules/overseas-master-data/api/dispatchCapacityDemands.test.ts @@ -0,0 +1,15 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listDispatchCapacityDemands } from './dispatchCapacityDemands' +vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data })) +beforeEach(() => vi.clearAllMocks()) +it('reads only dispatch demand summaries and preserves unknown quantities distinctly from zero', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { page: '1', size: '20', total: '1', pages: '1', records: [{ dispatchNo: 'PC-1', vehiclePalletSpaces: '30', scheduledPalletCount: '0', remainingPalletSpaces: null, remainingWeightKg: null, remainingVolumeCbm: null, capacityKnown: false }] } }) + const value=await listDispatchCapacityDemands({page:1,size:20,origin:'测试仓'}) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/capacity-demands',{params:{page:1,size:20,origin:'测试仓'}}) + expect(value).toMatchObject({total:1,records:[{dispatchNo:'PC-1',vehiclePalletSpaces:30,scheduledPalletCount:0,remainingPalletSpaces:null}]}) +}) +it.each(['invalid', -1, true])('rejects invalid capacity quantities %s', async remainingPalletSpaces => { + vi.mocked(http.get).mockResolvedValue({data:{page:1,size:20,total:1,pages:1,records:[{remainingPalletSpaces}]}}) + await expect(listDispatchCapacityDemands({page:1,size:20})).rejects.toThrow('数量无效') +}) diff --git a/src/modules/overseas-master-data/api/dispatchCapacityDemands.ts b/src/modules/overseas-master-data/api/dispatchCapacityDemands.ts new file mode 100644 index 00000000..6314b764 --- /dev/null +++ b/src/modules/overseas-master-data/api/dispatchCapacityDemands.ts @@ -0,0 +1,23 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +export interface DispatchCapacityDemand { + dispatchNo: string; warehouseName: string; route: string | null; plannedDepartureAt: string | null + vehiclePalletSpaces: number | null; scheduledPalletCount: number | null; remainingPalletSpaces: number | null + remainingWeightKg: number | null; remainingVolumeCbm: number | null; capacityKnown: boolean; capacityReason: string | null +} +interface Page { records: DispatchCapacityDemand[]; page: number; size: number; total: number; pages: number } +interface Query { page: number; size: number; origin?: string; destination?: string } +const quantity = (value: unknown): number => { + if (value == null || typeof value === 'string' && value.trim() === '') throw new Error('散板需求数量缺失') + if (typeof value !== 'number' && typeof value !== 'string') throw new Error('散板需求数量无效') + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) throw new Error('散板需求数量无效') + return parsed +} +export async function listDispatchCapacityDemands(query: Query): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope>('/platform/v1/last-leg/dispatches/capacity-demands', { params: query })) + const nullable = (number: unknown) => number == null ? null : quantity(number) + return { ...value, page: quantity(value.page), size: quantity(value.size), total: quantity(value.total), pages: quantity(value.pages), records: value.records.map(row => ({ ...row, + vehiclePalletSpaces: nullable(row.vehiclePalletSpaces), scheduledPalletCount: nullable(row.scheduledPalletCount), remainingPalletSpaces: nullable(row.remainingPalletSpaces), + remainingWeightKg: nullable(row.remainingWeightKg), remainingVolumeCbm: nullable(row.remainingVolumeCbm), + })) } +} diff --git a/src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.test.ts b/src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.test.ts new file mode 100644 index 00000000..0ca30bf2 --- /dev/null +++ b/src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.test.ts @@ -0,0 +1,40 @@ +import { shallowMount, flushPromises } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +import View from './DispatchCapacityDemandsView.vue' +import * as api from '../api/dispatchCapacityDemands' +vi.mock('../api/dispatchCapacityDemands', () => ({ listDispatchCapacityDemands: vi.fn() })) +const row: api.DispatchCapacityDemand = { dispatchNo: 'PC-TEST', warehouseName: '测试仓', route: '测试仓 → ONT8', plannedDepartureAt: null, vehiclePalletSpaces: 30, scheduledPalletCount: 18, remainingPalletSpaces: null, remainingWeightKg: null, remainingVolumeCbm: null, capacityKnown: false, capacityReason: '占用标准托位、重量及体积待确认' } +const result = { records: [row], page: 1, size: 20, total: 1, pages: 1 } +const layout = { template: '
' } +function mount() { return shallowMount(View, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'resultsVisible'], emits: ['update:page'], template: '
' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search'], template: '
' }, LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '
' } } } }) } +beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.listDispatchCapacityDemands).mockResolvedValue(result) }) +it('uses the dispatch source and never presents registration, publication or legacy trip navigation', async () => { + const wrapper = mount(); await flushPromises() + expect(api.listDispatchCapacityDemands).toHaveBeenCalledExactlyOnceWith({ page: 1, size: 20, origin: undefined, destination: undefined }) + const table = wrapper.getComponent({ name: 'DataTable' }) + expect(table.props('data')).toEqual([row]) + const columns = table.props('columns') as { key: string; formatter?: (value: api.DispatchCapacityDemand) => string }[] + expect(columns.some(c => c.key === 'actions')).toBe(false) + expect(columns.find(c => c.key === 'remainingPalletSpaces')?.formatter?.(row)).toBe('待确认') + expect(columns.find(c => c.key === 'remainingPalletSpaces')?.formatter?.({ ...row, capacityKnown: true, remainingPalletSpaces: 12 })).toBe('12') + expect(wrapper.findAll('button')).toHaveLength(0) + wrapper.unmount() +}) +it('applies warehouse and destination queries and resets pagination only once', async () => { + const wrapper=mount(); await flushPromises() + wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2); await flushPromises() + const query=wrapper.getComponent({name:'QueryToolbar'}) + query.vm.$emit('update:modelValue',{origin:'洛杉矶',destination:'ONT8'}); await flushPromises(); query.vm.$emit('search'); await flushPromises() + expect(api.listDispatchCapacityDemands).toHaveBeenCalledTimes(3) + expect(api.listDispatchCapacityDemands).toHaveBeenLastCalledWith({page:1,size:20,origin:'洛杉矶',destination:'ONT8'}) + wrapper.unmount() +}) +it('preserves the query and retries an explicit read error', async () => { + vi.mocked(api.listDispatchCapacityDemands).mockRejectedValueOnce(new Error('offline')) + const wrapper=mount(); await flushPromises() + expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(false) + wrapper.getComponent({name:'LoadErrorState'}).vm.$emit('retry'); await flushPromises() + expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true) + expect(api.listDispatchCapacityDemands).toHaveBeenCalledTimes(2) + wrapper.unmount() +}) diff --git a/src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.vue b/src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.vue new file mode 100644 index 00000000..67113672 --- /dev/null +++ b/src/modules/overseas-master-data/pages/DispatchCapacityDemandsView.vue @@ -0,0 +1,48 @@ + + diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts index 766301f6..9ec174cb 100644 --- a/src/modules/overseas-master-data/routes.ts +++ b/src/modules/overseas-master-data/routes.ts @@ -5,7 +5,7 @@ export const overseasMasterDataRoutes = [ { path: 'overseas-master-data/capacity-offers', name: 'overseas-master-data-capacity-offers', - component: () => import('./pages/CapacityOffersView.vue'), + component: () => import('./pages/DispatchCapacityDemandsView.vue'), meta: { title: '散板需求', description: '查看车次余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, }, { diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index 155fb6c8..29f4cb3a 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -1,5 +1,15 @@ # 海外仓原型核对矩阵 +## 排车发布内部散板需求(2026-10-21) + +依据本轮用户确认默认勾选发布,详见拼车主契约当前页面范围。 + +| 页面/操作 | 当前承载与最小调整 | 验收 | +|---|---|---| +| 智能排车/新建空单或选货转换 | 保持原弹框,在每车计划信息内增加默认勾选的“发布公共散板需求”;每车独立提交,不新增发布页面 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 | +| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 同上 | + + ## 2026-10-21 智能排车区域入口替代仓库搜索 用户明确删除查询区海外仓选择,仅保留“全部/洛杉矶/纽约”。界面移除顶部仓库框,区域筛选直接提交服务端;候选 warehouseId 不传时跨仓数据库分页,首托确定草稿仓并继续同仓安排,已有排车/预约仍显式指定仓库。空单模式在排车设置中保留起运仓,避免无来源时猜测仓库。正式业务边界维护于《排车列表统一作业改造契约》的区域入口确认段。 diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts index d31e3d18..ac79fad9 100644 --- a/src/modules/overseas-warehouse/api/dispatches.test.ts +++ b/src/modules/overseas-warehouse/api/dispatches.test.ts @@ -18,7 +18,7 @@ beforeEach(() => { describe('native whole-vehicle dispatch API', () => { it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => { const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion } - const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] } + const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] } await api.createDispatch(create); await api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }); await api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] }) expect(vi.mocked(http.post).mock.calls[0]![1]).toBe(create) expect(vi.mocked(http.post).mock.calls[1]![1]).toMatchObject({ batchAllocations: [{ recordVersion }] }) @@ -26,7 +26,7 @@ describe('native whole-vehicle dispatch API', () => { }) it.each([undefined, null, -1, '', 'bad', 9007199254740992])('rejects missing or invalid nested batch version %s before any write or candidate handoff', async value => { const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: value } as unknown as DispatchBatchAllocation - const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] } + const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] } await expect(Promise.resolve().then(() => api.createDispatch(create))).rejects.toThrow('重新读取候选') await expect(Promise.resolve().then(() => api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }))).rejects.toThrow('重新读取候选') await expect(Promise.resolve().then(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] }))).rejects.toThrow('重新读取候选') @@ -36,7 +36,7 @@ describe('native whole-vehicle dispatch API', () => { }) it('rejects inconsistent original versions of the same batch across conversion groups', () => { const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: '9007199254740993' } - const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] } + const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] } expect(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'A', dispatch: create }, { draftContainerId: 'B', dispatch: { ...create, batchAllocations: [{ ...allocation, recordVersion: '9007199254740994' }] } }] })).toThrow('同一预约批次的版本不一致') expect(http.post).not.toHaveBeenCalled() }) @@ -71,7 +71,7 @@ describe('native whole-vehicle dispatch API', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, paramsSerializer: { indexes: null } }) }) it('creates an empty real dispatch without an artificial customer or cargo', async () => { - const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] } + const request: CreateDispatch = { publishLooseboardDemand: true, idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] } await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request) }) it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => { @@ -97,7 +97,7 @@ describe('native whole-vehicle dispatch API', () => { expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 })) }) it('updates vehicle resources and timing with the explicit dispatch version', async () => { - const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null } + const request: UpdateDispatch = { publishLooseboardDemand: true, ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null } await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request) await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本') }) diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts index a2aec516..2cad91fa 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts @@ -82,3 +82,24 @@ it('allows saving photos while loading and keeps the loading stage', async () => expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.text()).toContain('可边装车边上传'); wrapper.unmount() }) + +it('renders the authenticated saved photo and fullscreen preview for a read-only Admin', async () => { + can.mockReturnValue(false) + const originalURL = URL + const create = vi.fn(() => 'blob:admin-loading-photo'), revoke = vi.fn() + vi.stubGlobal('URL', class extends originalURL { static createObjectURL = create; static revokeObjectURL = revoke }) + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING', photos: [photo] })) + const image = new Blob(['verified image'], { type: 'image/png' }) + vi.mocked(api.readLoadingPhoto).mockResolvedValue(image) + const wrapper = setup() + try { + await flushPromises() + expect(api.readLoadingPhoto).toHaveBeenCalledWith('PC/1', photo) + expect(create).toHaveBeenCalledWith(image) + const preview = wrapper.findComponent({ name: 'ElImage' }) + expect(preview.props('src')).toBe('blob:admin-loading-photo') + expect(preview.props('previewSrcList')).toEqual(['blob:admin-loading-photo']) + expect(wrapper.find('input[type="file"]').exists()).toBe(false) + } finally { wrapper.unmount(); vi.stubGlobal('URL', originalURL) } + expect(revoke.mock.calls[0]?.[0]).toBe('blob:admin-loading-photo') +}) diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue index 17dba294..026ce0f2 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue @@ -85,7 +85,7 @@ onMounted(() => { } catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') } if (!corrupted.value) void load() }) -onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(URL.revokeObjectURL) }) +onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) }) defineExpose({ busy }) diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts index 0b19441d..6d86eb1a 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts @@ -174,3 +174,15 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ cargo: [source().selection] }) })] })) } finally { wrapper.unmount() } }) + +it('defaults publication on for every new dispatch and carries opt-out in the atomic draft conversion', async () => { + vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) })) + const wrapper = mount(SmartDispatchDialog, options); await flushPromises() + const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise; form: { publishLooseboardDemand: boolean } } + state.restoreDraft('server-token'); await flushPromises() + expect(state.form.publishLooseboardDemand).toBe(true) + state.form.publishLooseboardDemand = false + await state.submit() + expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: false }) })] })) + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue index cdfc400f..6cfa4f21 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue @@ -44,10 +44,10 @@ const { candidates, page, size, total } = candidatePage const loading = computed(() => working.value || candidatePage.loading.value) const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' }) const warehouseTab = ref('ALL') -const settings = reactive>({}) +const settings = reactive>({}) const currentSettings = computed(() => { const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected' - settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' } } + settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', publishLooseboardDemand: true } } return settings[key]! }) const form = computed(() => currentSettings.value.form) @@ -164,7 +164,7 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch { requireBatchVersions(cargo.flatMap(row => row.appointmentBatches)) if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取') const config = containerId ? settings[containerId] : currentSettings.value - return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) } + return { publishLooseboardDemand: config?.form.publishLooseboardDemand ?? true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) } } async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise { if (!canCreate.value || loading.value || saving.value || pending.value) return false @@ -303,6 +303,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati

{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}选填

时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。

+
发布公共散板需求
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts index 80012bad..578d5873 100644 --- a/src/modules/overseas-warehouse/dispatchContracts.ts +++ b/src/modules/overseas-warehouse/dispatchContracts.ts @@ -35,17 +35,17 @@ export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXC export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER' export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string } export interface MarkDispatchException extends VersionCommand { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] } -export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] } +export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean } export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string } -export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] } +export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[]; publishLooseboardDemand: boolean } export interface VersionCommand { recordVersion: Version; idempotencyKey: string } -export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null } +export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; publishLooseboardDemand: boolean } export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] } export interface ReasonCommand extends VersionCommand { reason: string } export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments } -export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number } +export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number } export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string } -export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null } +export interface LoadingComplete extends VersionCommand { manifestHash: string; remark: string | null } export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] } export interface SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number } export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number } diff --git a/src/modules/overseas-warehouse/dispatchTestFixtures.ts b/src/modules/overseas-warehouse/dispatchTestFixtures.ts index 76fcb56b..d388c742 100644 --- a/src/modules/overseas-warehouse/dispatchTestFixtures.ts +++ b/src/modules/overseas-warehouse/dispatchTestFixtures.ts @@ -8,7 +8,7 @@ export function sharedPalletCandidates(): Candidate[] { const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] } return rows.map(row => ({ ...row, selectionGroup: group })) } -export function detail(overrides: Partial = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } } +export function detail(overrides: Partial = {}): DispatchDetail { return { publishLooseboardDemand: true, taskType: 'DELIVERY', dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } } export function listRow(overrides: Partial = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } } export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } } export function candidate(overrides: Partial = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }