Merge origin/release/test and preserve procurement direction with request unlock
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
|
||||
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
|
||||
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
## 排车发布内部散板需求(2026-10-21)
|
||||
|
||||
依据本轮用户确认默认勾选发布,详见拼车主契约当前页面范围。
|
||||
依据用户确认及 2026-10-22 简化要求,详见拼车主契约当前页面范围。
|
||||
|
||||
| 页面/操作 | 当前承载与最小调整 | 验收 |
|
||||
|---|---|---|
|
||||
| 智能排车/新建空单或选货转换 | 保持原弹框,在每车计划信息内增加默认勾选的“发布公共散板需求”;每车独立提交,不新增发布页面 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 |
|
||||
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 同上 |
|
||||
| 智能排车/新建空单或选货转换 | 保持原弹框,搜索区增加默认不勾选的“发布公共需求”,统一用于本次生成的所有排车单;移除下方计划时间与备注区,沿用关联排车维护 | 3文件59项定向测试通过;组件及两份测试类型检查通过,规划测试原有VueWrapper辅助函数类型不匹配仍阻断其类型检查;待人工1920×1080、100%截图确认 |
|
||||
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 沿用此前8文件115项定向测试及受影响文件类型检查结果;本轮未修改,待人工1920×1080、100%截图确认 |
|
||||
|
||||
|
||||
## 2026-10-21 智能排车区域入口替代仓库搜索
|
||||
@@ -60,7 +60,7 @@
|
||||
| 页面/操作 | 截图承载与当前实现 | 验证 |
|
||||
|---|---|---|
|
||||
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
|
||||
| 管理资料/装车资料 | 原居中弹框内两列共五个装车照片框;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
|
||||
| 管理资料/装车资料 | 原居中弹框内五行装车照片表格(2026-10-22 简化确认),保留图片预览;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
|
||||
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
|
||||
|
||||
## 2026-10-21 智能排车恢复10月18日布局
|
||||
@@ -445,6 +445,6 @@
|
||||
|
||||
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
|
||||
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
|
||||
|
||||
@@ -15,10 +15,19 @@ beforeEach(() => {
|
||||
vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
|
||||
vi.mocked(http.delete).mockResolvedValue({ data: draft() })
|
||||
})
|
||||
it('reads and writes original dispatch appointment allocations with lossless versions', async () => {
|
||||
const value = { dispatchNo: 'PC/1', recordVersion: '9007199254740993', items: [], edit: { allowed: true, blockCode: null, blockReason: null } }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
|
||||
expect(await api.getDispatchAppointmentAllocations('PC/1')).toEqual(value)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations')
|
||||
const body = { recordVersion: 0, idempotencyKey: 'allocation-key', dispatchItemIds: ['11'], batchAllocations: [{ planPalletId: '21', batchKey: 'FBA_APPOINTMENT:9', customerId: '101', sourceKey: 'ORDER:1', destinationKey: 'FBA:ACY2', packageCount: 10, recordVersion: 0 }] }
|
||||
await api.saveDispatchAppointmentAllocations('PC/1', body)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations', body)
|
||||
})
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-1' }
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'APPT-1' }] }
|
||||
const value = detail()
|
||||
value.vehicleInfo.maintenance = maintenance
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['9007199254740993']
|
||||
@@ -26,7 +35,7 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
const saved = await api.getDispatch('PC/1')
|
||||
expect(saved.vehicleInfo.maintenance).toEqual(maintenance)
|
||||
expect(saved.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['9007199254740993'])
|
||||
await api.updateDispatch('PC/1', { recordVersion: saved.recordVersion, idempotencyKey: 'maintenance-key', vehicle: saved.vehicleInfo.vehicle, driver: saved.vehicleInfo.driver, maintenance, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, publishLooseboardDemand: true })
|
||||
await api.updateDispatch('PC/1', { recordVersion: saved.recordVersion, idempotencyKey: 'maintenance-key', vehicle: saved.vehicleInfo.vehicle, driver: saved.vehicleInfo.driver, maintenance, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', expect.objectContaining({ maintenance, vehicle: expect.objectContaining({ evidenceFileIds: ['9007199254740993'] }) }))
|
||||
expect(vi.mocked(http.put).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
@@ -111,7 +120,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 = { publishLooseboardDemand: true, ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
||||
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
||||
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
|
||||
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { AppointmentAllocations, AppointmentAllocationCommand } from '../dispatchContracts'
|
||||
import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
|
||||
@@ -17,6 +18,19 @@ export async function listDispatches(query: DispatchQuery) {
|
||||
}
|
||||
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
|
||||
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
|
||||
export async function getDispatchAppointmentAllocations(no: string) {
|
||||
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
|
||||
requireVersion(value.recordVersion)
|
||||
value.items.forEach(item => { requireBatchVersions(item.allocations); item.sources.forEach(source => source.options.forEach(option => requireVersion(option.recordVersion))) })
|
||||
return value
|
||||
}
|
||||
export const saveDispatchAppointmentAllocations = (no: string, body: AppointmentAllocationCommand) => {
|
||||
versioned(body); requireBatchVersions(body.batchAllocations)
|
||||
return postAppointmentAllocations(no, body)
|
||||
}
|
||||
async function postAppointmentAllocations(no: string, body: AppointmentAllocationCommand) {
|
||||
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/appointment-allocations`, body)))
|
||||
}
|
||||
export const getDispatchRoute = (no: string) => get<DispatchRoute>(`${path(no)}/route`)
|
||||
export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/route`, versioned(body)))) }
|
||||
const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
|
||||
|
||||
@@ -118,7 +118,7 @@ async function save() {
|
||||
<component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
|
||||
<div class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||||
<section v-if="!embedded" class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||||
<template v-if="snapshot">
|
||||
<section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import ElementPlus, { ElInputNumber } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { AppointmentAllocations } from '../dispatchContracts'
|
||||
import { command } from '../dispatchTestFixtures'
|
||||
|
||||
const access = vi.hoisted(() => ({ can: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.can }) }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatchAppointmentAllocations: vi.fn(), saveDispatchAppointmentAllocations: vi.fn() }))
|
||||
function facts(): AppointmentAllocations {
|
||||
return { dispatchNo: 'PC/1', recordVersion: '0', edit: { allowed: true, blockCode: null, blockReason: null }, items: ['11', '12'].map((id, index) => ({
|
||||
dispatchItemId: id, planPalletId: `${index + 21}`, customerId: '101', customerName: '客户甲', palletNo: `PALLET-${index + 1}`, allocations: [],
|
||||
sources: [{ sourceKey: 'ORDER:1', orderNo: 'WC-1', inboundCode: 'IN-1', destinationKey: 'FBA:ACY2', packageCount: 10, blockReasons: [],
|
||||
options: [{ batchKey: 'FBA_APPOINTMENT:9', batchNo: 'YY-9', recordVersion: '0', availablePackages: 20, confirmationNo: 'CONF-9', appointmentAt: '2026-10-26T10:00:00', appointmentEndAt: null, timezone: 'America/New_York' }] }],
|
||||
})) }
|
||||
}
|
||||
async function open() {
|
||||
const wrapper = mount(DispatchAppointmentAllocationsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.can = true
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(facts())
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockResolvedValue(command('EDIT'))
|
||||
})
|
||||
it('links both pallets to the confirmed appointment on the original dispatch with explicit version zero', async () => {
|
||||
const wrapper = await open()
|
||||
expect(wrapper.text()).toContain('WC-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('America/New_York')
|
||||
expect(wrapper.text()).not.toContain('ORDER:1')
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1)
|
||||
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: '0', dispatchItemIds: ['11', '12'],
|
||||
batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => {
|
||||
const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('本票剩余可排量不足')
|
||||
wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 5); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('须完整分配 10 箱')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('preserves edited quantities after rejection and retries with current versions and a new command key', async () => {
|
||||
const value = facts(); value.items = value.items.slice(0, 1)
|
||||
value.items[0]!.sources[0]!.options.push({ ...value.items[0]!.sources[0]!.options[0]!, batchKey: 'FBA_APPOINTMENT:10', batchNo: 'YY-10' })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); const inputs = wrapper.findAllComponents(ElInputNumber)
|
||||
inputs[0]!.vm.$emit('update:modelValue', 6); inputs[1]!.vm.$emit('update:modelValue', 4); await flushPromises()
|
||||
const latest = structuredClone(value); latest.recordVersion = '2'; latest.items[0]!.sources[0]!.options.forEach(option => { option.recordVersion = '3' })
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('版本已变化'))
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([6, 4])
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
const calls = vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls
|
||||
expect(calls).toHaveLength(2); expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
|
||||
expect(calls[1]![1]).toMatchObject({ recordVersion: '2', batchAllocations: [{ recordVersion: '3', packageCount: 6 }, { recordVersion: '3', packageCount: 4 }] })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains an unavailable former choice after a failed save until the user clears it', async () => {
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('预约已变化'))
|
||||
const latest = facts(); latest.items[0]!.sources[0]!.options = []
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已失效'); expect(wrapper.findAllComponents(ElInputNumber)[0]!.props('modelValue')).toBe(10)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it.each(['permission', 'gate', 'unconfirmed'] as const)('does not save when blocked by %s', async mode => {
|
||||
const value = facts()
|
||||
if (mode === 'permission') access.can = false
|
||||
if (mode === 'gate') value.edit = { allowed: false, blockCode: 'NOT_ELIGIBLE', blockReason: '本车已有真实扫码' }
|
||||
if (mode === 'unconfirmed') value.items[0]!.sources[0] = { ...value.items[0]!.sources[0]!, options: [], blockReasons: ['请先确认送仓时间'] }
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getDispatchAppointmentAllocations, saveDispatchAppointmentAllocations } from '../api/dispatches'
|
||||
import { DispatchInputError, requireVersion, type AppointmentAllocations, type AppointmentAllocationItem, type AppointmentAllocationSource, type AppointmentAllocationOption, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
interface Choice extends AppointmentAllocationOption { packages: number; unavailable: boolean }
|
||||
interface SourceRow { key: string; item: AppointmentAllocationItem; source: AppointmentAllocationSource; choices: Choice[] }
|
||||
const context = ref<AppointmentAllocations | null>(null), rows = ref<SourceRow[]>([])
|
||||
const loading = ref(false), saving = ref(false), factsReady = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let generation = 0
|
||||
const permitted = computed(() => access.can('platform:overseas-warehouse:dispatch:edit') && context.value?.edit.allowed === true)
|
||||
const allocationKey = (item: AppointmentAllocationItem, source: AppointmentAllocationSource, batch: string) => JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey, batch])
|
||||
const quotaKey = (row: SourceRow, batch: string) => JSON.stringify([batch, row.item.customerId, row.source.sourceKey, row.source.destinationKey])
|
||||
const validation = computed(() => {
|
||||
const used = new Map<string, number>()
|
||||
for (const row of rows.value) {
|
||||
if (row.source.blockReasons.length) return row.source.blockReasons[0]!
|
||||
if (!Number.isSafeInteger(row.source.packageCount) || (row.source.packageCount ?? 0) <= 0) return '本票箱数尚未核实,请刷新本车货物'
|
||||
let total = 0
|
||||
for (const choice of row.choices) {
|
||||
if (!Number.isSafeInteger(choice.packages) || choice.packages < 0) return '分配箱数须为非负整数'
|
||||
if (!choice.packages) continue
|
||||
if (choice.unavailable) return '原选择的预约已失效,请将对应箱数清零后重新选择'
|
||||
total += choice.packages
|
||||
const key = quotaKey(row, choice.batchKey), count = (used.get(key) || 0) + choice.packages
|
||||
if (count > choice.availablePackages) return `预约 ${choice.batchNo} 的本票剩余可排量不足,请调整分配箱数`
|
||||
used.set(key, count)
|
||||
}
|
||||
if (total !== row.source.packageCount) return `托盘 ${row.item.palletNo} 的本票货物须完整分配 ${row.source.packageCount} 箱`
|
||||
}
|
||||
return rows.value.length ? '' : '本车暂无可关联正式预约的FBA货物'
|
||||
})
|
||||
const canSave = computed(() => factsReady.value && permitted.value && !loading.value && !saving.value && !validation.value)
|
||||
async function load(preserve = false) {
|
||||
const current = ++generation, no = props.dispatchNo
|
||||
const previous = new Map(rows.value.flatMap(row => row.choices.map(choice => [allocationKey(row.item, row.source, choice.batchKey), choice] as const)))
|
||||
loading.value = true; factsReady.value = false
|
||||
if (!preserve) { error.value = null; context.value = null; rows.value = [] }
|
||||
try {
|
||||
const value = await getDispatchAppointmentAllocations(no)
|
||||
if (current !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('预约分配与当前排车单不一致,请重新打开')
|
||||
requireVersion(value.recordVersion)
|
||||
const next: SourceRow[] = []
|
||||
for (const item of value.items) for (const source of item.sources.filter(source => source.destinationKey.startsWith('FBA:'))) {
|
||||
const key = JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey])
|
||||
const choices: Choice[] = source.options.map(option => {
|
||||
const retained = item.allocations.find(a => a.batchKey === option.batchKey && a.sourceKey === source.sourceKey && a.destinationKey === source.destinationKey)
|
||||
const old = preserve ? previous.get(allocationKey(item, source, option.batchKey)) : undefined
|
||||
return { ...option, packages: old?.packages ?? retained?.packageCount ?? (!preserve && !item.allocations.length && source.options.length === 1 ? source.packageCount || 0 : 0), unavailable: false }
|
||||
})
|
||||
if (preserve) for (const oldRow of rows.value.filter(row => row.key === key)) for (const old of oldRow.choices)
|
||||
if (old.packages > 0 && !choices.some(choice => choice.batchKey === old.batchKey)) choices.push({ ...old, unavailable: true })
|
||||
next.push({ key, item, source, choices })
|
||||
}
|
||||
context.value = value; rows.value = next; factsReady.value = true
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本车正式预约读取失败,请刷新重试') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave.value || !context.value) return
|
||||
const current = generation, no = props.dispatchNo
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
const batchAllocations: DispatchBatchAllocation[] = rows.value.flatMap(row => row.choices.filter(choice => choice.packages > 0).map(choice => ({
|
||||
planPalletId: row.item.planPalletId, customerId: row.item.customerId, sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey,
|
||||
batchKey: choice.batchKey, recordVersion: requireVersion(choice.recordVersion), packageCount: choice.packages,
|
||||
})))
|
||||
await saveDispatchAppointmentAllocations(no, { recordVersion: requireVersion(context.value.recordVersion), idempotencyKey: crypto.randomUUID(),
|
||||
dispatchItemIds: [...new Set(rows.value.map(row => row.item.dispatchItemId))], batchAllocations })
|
||||
if (current !== generation) return
|
||||
ElMessage.success('正式预约已关联,请核对运输时间后重新提交审核')
|
||||
emit('saved'); open.value = false
|
||||
} catch (cause) {
|
||||
if (current === generation) { error.value = getApiErrorDetails(cause, '正式预约关联未完成'); await load(true) }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
watch([open, () => props.dispatchNo], ([value]) => { if (value) void load(); else { generation++; factsReady.value = false } }, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="`关联正式预约 · ${dispatchNo}`" width="1100px" fullscreenable
|
||||
confirm-text="保存关联" :loading="loading" :confirm-loading="saving" :confirm-disabled="!canSave" :close-on-press-escape="!saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="正式预约关联未完成" :message="error.message" :loading="loading || saving" retry-text="刷新当前预约" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="context && !permitted" :title="context.edit.blockReason || '当前无关联预约资格'" type="warning" :closable="false" show-icon />
|
||||
<p class="density-dialog-note">按本车每托、每票货物核对已确认的正式预约及箱数。保存后保留原排车,重新提交两级审核。</p>
|
||||
<div class="appointment-table"><table class="density-table density-table--editable"><thead><tr><th>客户/托盘</th><th>订单号/入仓码</th><th>目的仓</th><th>本票箱数</th><th>正式预约/当地送达时间</th><th>本票可排箱数</th><th>分配箱数</th></tr></thead><tbody>
|
||||
<template v-for="row in rows" :key="row.key">
|
||||
<tr v-for="choice in row.choices" :key="choice.batchKey"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td>{{ choice.batchNo || '—' }}<span v-if="choice.unavailable">(已失效)</span><br>{{ choice.confirmationNo || '—' }}<br>{{ choice.appointmentAt?.replace('T', ' ') }}<template v-if="choice.appointmentEndAt"> ~ {{ choice.appointmentEndAt.replace('T', ' ') }}</template><br>{{ choice.timezone }}</td><td>{{ choice.availablePackages }}<span v-if="!choice.unavailable">(同票共用)</span></td><td><el-input-number v-model="choice.packages" :min="0" :precision="0" :controls="false" :disabled="loading || saving || !permitted" :aria-label="`${row.item.palletNo} ${choice.batchNo} 分配箱数`" /></td></tr>
|
||||
<tr v-if="!row.choices.length"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td colspan="3">{{ row.source.blockReasons.join(';') || '暂无已确认的正式预约,请先在预约列表确认送仓时间' }}</td></tr>
|
||||
</template><tr v-if="!loading && !rows.length"><td colspan="7">本车暂无可关联正式预约的FBA货物</td></tr>
|
||||
</tbody></table></div>
|
||||
<el-alert v-if="factsReady && permitted && validation" :title="validation" type="warning" :closable="false" show-icon />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appointment-table { overflow-x: auto; }
|
||||
.appointment-table table { min-width: 960px; }
|
||||
.appointment-table :deep(.el-input-number) { width: 112px; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import DispatchAppointments from './DispatchAppointments.vue'
|
||||
import type { FbaAppointment } from '../dispatchContracts'
|
||||
|
||||
it('shows each destination once and keeps all shipment numbers from its grouped projection', () => {
|
||||
const first: FbaAppointment = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'A', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }
|
||||
const wrapper = mount(DispatchAppointments, { props: { appointments: [first, { ...first, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8', batchKey: 'FBA_APPOINTMENT:2', shipmentNumbers: ['SHIP-3'] }] } })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('SHIP-1、SHIP-2'); expect(wrapper.text()).toContain('SHIP-3')
|
||||
expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
})
|
||||
it('shows missing confirmation as a dash and never substitutes a batch number or planned time', () => {
|
||||
const wrapper = mount(DispatchAppointments, { props: { appointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'AP-PLAN', appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: null, status: 'PENDING', confirmedAt: null, blockReason: null }] } })
|
||||
expect(wrapper.findAll('tbody td').map(cell => cell.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.text()).not.toContain('AP-PLAN')
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { FbaAppointment } from '../dispatchContracts'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
withDefaults(defineProps<{ appointments: FbaAppointment[]; showShipmentNumbers?: boolean }>(), { showShipmentNumbers: true })
|
||||
</script>
|
||||
<template>
|
||||
<table class="density-table" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th v-if="showShipmentNumbers">货件号</th></tr></thead><tbody>
|
||||
<tr v-for="appointment in appointments" :key="appointment.destinationKey"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td><td v-if="showShipmentNumbers">{{ appointment.shipmentNumbers?.join('、') || '—' }}</td></tr>
|
||||
<tr v-if="!appointments.length"><td :colspan="showShipmentNumbers ? 4 : 3">—</td></tr>
|
||||
</tbody></table>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits
|
||||
function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments {
|
||||
return { deliveryNo: no, customerId, version: 1, bolNo: 'BOL-ORIGINAL', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'RECEIPT' ? 1 : 0, files: item.category === 'RECEIPT' ? [{ fileId: '99', fileName: 'existing-pod.pdf', contentType: 'application/pdf', sizeBytes: 10, uploadedAt: '2026-10-21T00:00:00' }] : [] })) }
|
||||
}
|
||||
function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
|
||||
function setup() { return mount(DispatchDocumentsDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); can.mockReturnValue(true)
|
||||
const value = detail(); value.stage = 'DELIVERING'
|
||||
@@ -32,6 +32,8 @@ it.each(['LOADING', 'AFTER_LOADING'] as const)('opens loading documents in %s be
|
||||
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(true)
|
||||
expect(wrapper.find('dispatch-evidence-files-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true')
|
||||
expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -39,6 +41,8 @@ it('reads existing delivery files in one dialog and saves receipt without requir
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
|
||||
expect(wrapper.text()).toContain('existing-pod.pdf')
|
||||
expect(wrapper.text()).not.toContain('资料核对')
|
||||
expect(wrapper.find('[aria-label="派送资料摘要"]').exists()).toBe(false)
|
||||
expect(wrapper.findAllComponents(DeliveryDocumentsDialog)).toHaveLength(1)
|
||||
await wrapper.get('.save').trigger('click'); await flushPromises()
|
||||
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
|
||||
@@ -48,13 +52,28 @@ it('reads existing delivery files in one dialog and saves receipt without requir
|
||||
})
|
||||
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
|
||||
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['BOL'])
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['RECEIPT'])
|
||||
expect(receipt.isVisible()).toBe(false)
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').isVisible()).toBe(true)
|
||||
expect(wrapper.find('dispatch-evidence-files-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('关联派送单资料')
|
||||
expect(wrapper.text()).not.toContain('车辆及司机附件')
|
||||
expect(wrapper.find('[data-category="BOL"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-category="LOADING_PHOTO"]').exists()).toBe(false)
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).text()).toContain('0/1 已上传')
|
||||
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).find('[aria-label="派送资料摘要"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('油耗图片')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').exists()).toBe(false)
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '签收文件')!.trigger('click')
|
||||
expect(receipt.isVisible()).toBe(true)
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(true)
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
|
||||
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -11,25 +11,21 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchDetail } from '../dispatchContracts'
|
||||
import { dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
|
||||
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
|
||||
const tab = ref('RECEIPT'), selected = ref('')
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
|
||||
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
|
||||
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
||||
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
|
||||
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy || loadingEditor.value?.busy))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy))
|
||||
let sequence = 0
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
async function load() {
|
||||
@@ -52,29 +48,26 @@ async function canLeave() {
|
||||
}
|
||||
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
|
||||
async function beforeClose(done: () => void) { if (await canLeave()) done() }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
|
||||
function saved() { emit('saved') }
|
||||
async function loadingSaved() { emit('saved'); await load() }
|
||||
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; fuelVisited.value = false; error.value = null
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null
|
||||
if (visible && props.dispatchNo) void load()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab !== 'FUEL' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
|
||||
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
|
||||
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="detail">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
|
||||
<h3 v-if="tab === 'LOADING'" class="density-dialog-section__title">关联派送单资料</h3>
|
||||
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
|
||||
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['BOL']" @saved="saved" />
|
||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
|
||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||
</div>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||
<DispatchVehicleDocuments v-if="fuelVisited && !beforeDeparture" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
|
||||
<section v-if="tab === 'LOADING'" class="density-dialog-section"><h3 class="density-dialog-section__title">车辆及司机附件</h3><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></section>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,17 @@ vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchE
|
||||
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
|
||||
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
|
||||
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
|
||||
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
|
||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('上传失败'))
|
||||
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
|
||||
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
|
||||
|
||||
@@ -23,7 +23,7 @@ const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
|
||||
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
|
||||
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
||||
const uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
|
||||
const uploadTypes = computed(() => (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind)) ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
|
||||
@@ -27,33 +27,37 @@ beforeEach(() => {
|
||||
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
|
||||
const table = wrapper.get('table[aria-label="装车照片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['装车照片 1', '装车照片 2', '装车照片 3', '装车照片 4', '装车照片 5'])
|
||||
await upload(wrapper)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('图片读取失败')
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the original binding and file after an unknown result and never uploads again', async () => {
|
||||
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
|
||||
await wrapper.findAll('button').find(item => item.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', original)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(wrapper)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ fileId: original.fileId })); expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definitive version conflict while retaining the uploaded file', async () => {
|
||||
it('uses the current version when the operator chooses a photo after a conflict', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '4' }))
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'DELIVERING', complete: true, recordVersion: '5' }))
|
||||
await wrapper.findAll('button').find(item => item.text() === '刷新并重试保存')!.trigger('click'); await flushPromises()
|
||||
await upload(wrapper)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '4', fileId: '101' }))
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('shows existing photos without exposing upload for a read-only account', async () => {
|
||||
can.mockReturnValue(false)
|
||||
|
||||
@@ -7,19 +7,17 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, recoverLoadingPhoto, uploadLoadingPhoto, type BindLoadingPhoto, type LoadingDocuments, type LoadingPhoto, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
interface Pending { slot: LoadingPhotoSlot; key: string; photo?: LoadingPhoto; binding?: BindLoadingPhoto; conflict?: boolean }
|
||||
const pending = ref<Pending | null>(null), corrupted = ref(false), images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo, storageKey = `dispatch-loading-documents:${actor}:${number}`
|
||||
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
|
||||
async function previews() {
|
||||
const photos = snapshot.value?.photos || []
|
||||
@@ -29,62 +27,30 @@ async function previews() {
|
||||
}))
|
||||
}
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function bind() {
|
||||
const item = pending.value
|
||||
if (!item?.photo || !snapshot.value) return
|
||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与装车照片框不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||
try {
|
||||
const result = await bindLoadingPhoto(number, item.slot, item.binding)
|
||||
accept(result); pending.value = null; persist(); emit('saved')
|
||||
ElMessage.success(result.stage === 'DELIVERING' && result.complete ? '装车资料已齐全,本车已进入派送中' : '装车照片已保存')
|
||||
await previews()
|
||||
} catch (cause) {
|
||||
const failure = getApiErrorDetails(cause, '装车照片保存失败')
|
||||
if (pending.value && /^(?:A0|HTTP_)?409$/.test(String(failure.code))) { pending.value.conflict = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !editable.value || !pending.value) return
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (pending.value.conflict) { accept(await getLoadingDocuments(number)); pending.value.binding = undefined; pending.value.conflict = false; persist() }
|
||||
if (!pending.value.photo) { pending.value.photo = await recoverLoadingPhoto(number, pending.value.key); ensureScope(); persist() }
|
||||
await bind()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||
finally { if (active) busy.value = false }
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getLoadingDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
await previews()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
|
||||
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
|
||||
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (raw) { const value = JSON.parse(raw) as Pending; if (!loadingPhotoSlots.includes(value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原装车照片请求记录无法读取'); pending.value = value }
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
|
||||
if (!corrupted.value) void load()
|
||||
})
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
@@ -92,24 +58,32 @@ defineExpose({ busy })
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
|
||||
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
|
||||
<div class="loading-photo-grid">
|
||||
<section v-for="(slot, index) in loadingPhotoSlots" :key="slot" class="density-dialog-section loading-photo-card">
|
||||
<h3 class="density-dialog-section__title">装车照片 {{ index + 1 }}</h3>
|
||||
<template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>装车照片 {{ index + 1 }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
||||
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||
</template>
|
||||
<div v-if="!snapshot?.photos.some(item => item.slot === slot)" class="loading-photo-empty">未上传</div>
|
||||
<label v-if="editable">上传/替换图片<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label>
|
||||
</section>
|
||||
</div>
|
||||
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.loading-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.loading-photo-image, .loading-photo-empty { width: 100%; height: 150px; margin-bottom: var(--admin-space-3); }
|
||||
.loading-photo-empty { display: grid; place-items: center; background: var(--cc-bg-soft, #f3f7ff); color: var(--cc-text-secondary, #64748b); }
|
||||
.loading-photo-card input { display: block; margin-top: var(--admin-space-2); max-width: 100%; }
|
||||
.loading-documents-table { table-layout: fixed; }
|
||||
.loading-documents-table td { overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 120px; }
|
||||
.photo-preview { width: 140px; }
|
||||
.photo-time { width: 190px; }
|
||||
.loading-photo-image { display: block; width: 100px; height: 64px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
|
||||
.photo-upload.is-disabled input { cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -3,18 +3,28 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { resources } from '../dispatchTestFixtures'
|
||||
import { listDrivers, listVehicles, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(listVehicles).mockResolvedValue({ records: [], page: 1, size: 100, total: 0, pages: 0 }) })
|
||||
it('carries the selected master license for preview without carrying another driver’s trip photos', async () => {
|
||||
const candidate: DriverDetail = { id: '22', driverNo: 'DR22', details: { name: '司机乙', phone: '555-0022', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE22', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2028-01-01', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '33', recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-23T00:00:00Z', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2028-01-01' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
|
||||
vi.mocked(listDrivers).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
|
||||
const value = resources()
|
||||
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchDrivers(): Promise<void>; selectDriver(id: string): void }
|
||||
await state.searchDrivers(); state.selectDriver('22'); await flushPromises()
|
||||
expect(wrapper.emitted('update:driver')!.at(-1)![0]).toMatchObject({ driverId: '22', evidence: { fileIds: [], masterLicenseFileId: '33' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous master snapshot fields read-only and uses a dash for missing facts (%s)', async (carrierName, phone) => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
|
||||
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
|
||||
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
|
||||
@@ -25,7 +35,7 @@ it('keeps master company and phone fields out of the existing non-continuous cre
|
||||
})
|
||||
it('shows the newly selected authorized vehicle company without copying it into the master command', async () => {
|
||||
const candidate: VehicleDetail = { id: 'vehicle-2', plateNumber: 'CA-200', model: '厢式货车', driverOrDispatchCompany: '新承运公司', phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValueOnce({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string): void }
|
||||
@@ -54,3 +64,20 @@ it.each([false, true])('requires each directory permission before reading candid
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('filters real unavailable statuses, sends dispatch context, and lets an invalid selection be cleared', async () => {
|
||||
const base: VehicleDetail = { id: 'good', plateNumber: 'GOOD', model: '厢式货车', driverOrDispatchCompany: null, phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [base, { ...base, id: 'bad', plateNumber: 'BAD', status: 'MAINTENANCE' }], page: 1, size: 100, total: 2, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'bad'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true, dispatchNo: 'PC-REAL' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前车辆已不可用')
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string | null): void }
|
||||
await state.searchVehicles(); await flushPromises()
|
||||
expect(listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_SERVICE', region: 'OVERSEAS', dispatchNo: 'PC-REAL' }))
|
||||
const options = wrapper.findAllComponents({ name: 'ElOption' })
|
||||
expect(options.find(item => item.props('value') === 'good')?.props('disabled')).toBe(false)
|
||||
expect(options.find(item => item.props('value') === 'bad')?.props('disabled')).toBe(true)
|
||||
state.selectVehicle('bad'); expect(wrapper.emitted('update:vehicle')).toBeUndefined()
|
||||
state.selectVehicle(null); expect(wrapper.emitted('update:vehicle')!.at(-1)![0]).toMatchObject({ vehicleId: null })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
|
||||
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
|
||||
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
|
||||
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
|
||||
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
|
||||
const availableVehicle = (row: VehicleDetail) => row.region === 'OVERSEAS' && row.status === 'IN_SERVICE'
|
||||
let eligibilitySequence = 0
|
||||
watch(() => [vehicle.value.mode, vehicle.value.vehicleId, props.dispatchNo] as const, async ([mode, id]) => {
|
||||
const sequence = ++eligibilitySequence
|
||||
currentVehicle.value = null; vehicleUnavailable.value = false
|
||||
if (mode !== 'MASTER' || !id || !vehicle.value.plate || !access.can(resourcePermission.vehicleList)) return
|
||||
try {
|
||||
const result = await listVehicles({ page: 1, size: 100, keyword: vehicle.value.plate, region: 'OVERSEAS', status: 'IN_SERVICE', dispatchNo: props.dispatchNo })
|
||||
if (sequence !== eligibilitySequence) return
|
||||
currentVehicle.value = result.records.find(row => row.id === id && availableVehicle(row)) || null
|
||||
vehicleUnavailable.value = !currentVehicle.value
|
||||
} catch (cause) { if (sequence === eligibilitySequence) error.value = cause instanceof Error ? cause.message : '当前车辆资格读取失败' }
|
||||
}, { immediate: true })
|
||||
let vehicleSequence = 0, driverSequence = 0
|
||||
async function searchVehicles(keyword = '') {
|
||||
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
|
||||
const sequence = ++vehicleSequence
|
||||
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (sequence === vehicleSequence) vehicles.value = result.records }
|
||||
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE', dispatchNo: props.dispatchNo }); if (sequence === vehicleSequence) vehicles.value = result.records.filter(availableVehicle) }
|
||||
catch (cause) { if (sequence === vehicleSequence) error.value = cause instanceof Error ? cause.message : '车辆候选读取失败' }
|
||||
}
|
||||
async function searchDrivers(keyword = '') {
|
||||
@@ -26,20 +41,23 @@ async function searchDrivers(keyword = '') {
|
||||
}
|
||||
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
|
||||
function changeDriver() { driver.value = { mode: driver.value.mode, driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
||||
function selectVehicle(id: string) {
|
||||
const selected = vehicles.value.find(row => row.id === id)
|
||||
function selectVehicle(id: string | null) {
|
||||
if (!id) { changeVehicle(); return }
|
||||
const selected = vehicleOptions.value.find(row => row.id === id)
|
||||
if (!selected || !availableVehicle(selected)) { error.value = '车辆当前不可用,请重新选择'; return }
|
||||
vehicle.value = { mode: 'MASTER', vehicleId: id, plate: selected?.plateNumber || null, carrierName: null, capacity: null, evidenceFileIds: [] }
|
||||
}
|
||||
function selectDriver(id: string) {
|
||||
const selected = drivers.value.find(row => row.id === id), end = driver.value.evidence?.expectedTaskEndAt || null
|
||||
driver.value = { mode: 'MASTER', driverId: id, name: selected?.details.name || null, phone: selected?.details.phone || null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: end } }
|
||||
driver.value = { mode: 'MASTER', driverId: id, name: selected?.details.name || null, phone: selected?.details.phone || null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: end, masterLicenseFileId: selected?.licenseFileId || null } }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<el-alert v-if="vehicleUnavailable" title="当前车辆已不可用,请清除或重新选择车辆;草稿可分步保存。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="continuous">
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
|
||||
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<template v-if="vehicle.mode === 'EXTERNAL'">
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
@@ -56,7 +74,7 @@ function selectDriver(id: string) {
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div class="resource-grid">
|
||||
<el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
|
||||
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
|
||||
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
|
||||
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
|
||||
|
||||
@@ -19,39 +19,43 @@ beforeEach(() => {
|
||||
vi.mocked(recoverVehiclePhoto).mockResolvedValue(photo)
|
||||
vi.mocked(bindVehiclePhoto).mockResolvedValue({ ...response('4'), photos: [{ ...photo, savedAt: '2026-10-21T00:00:00Z' }] })
|
||||
})
|
||||
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
|
||||
it('does not restore failed bindings after reopening and allows a new upload', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
|
||||
const first = setup(); await flushPromises(); await upload(first)
|
||||
const table = first.get('table[aria-label="油耗图片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '图片类别', '文件名称', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['起始油耗表盘', '结束油耗表盘'])
|
||||
const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2]
|
||||
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
|
||||
first.unmount()
|
||||
const reopened = setup(); await flushPromises()
|
||||
await reopened.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(reopened)
|
||||
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(reopened.text()).toContain('fuel.png')
|
||||
expect(reopened.get('table tbody tr').text()).toContain('2026-10-21 00:00:00Z')
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
reopened.unmount()
|
||||
})
|
||||
it('keeps uncertain uploads for original-key recovery and never resubmits the file', async () => {
|
||||
it('unlocks uncertain uploads for a new file selection', async () => {
|
||||
vi.mocked(uploadVehiclePhoto).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const originalKey = vi.mocked(uploadVehiclePhoto).mock.calls[0]![3]
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(recoverVehiclePhoto).toHaveBeenCalledWith('PC/1', originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(wrapper)
|
||||
expect(recoverVehiclePhoto).not.toHaveBeenCalled(); expect(vi.mocked(uploadVehiclePhoto).mock.calls[1]![3]).not.toBe(originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(bindVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes only a known version conflict and reuses the uploaded file', async () => {
|
||||
it('allows a new photo and current version after a binding conflict', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new ApiResponseError('运营记录版本已变化,请刷新后重试', 'A0409', 409))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const originalKey = vi.mocked(bindVehiclePhoto).mock.calls[0]![2].idempotencyKey
|
||||
vi.mocked(getVehicleDocuments).mockResolvedValue(response('8'))
|
||||
await wrapper.findAll('button').find(button => button.text() === '刷新版本并保存已上传图片')!.trigger('click'); await flushPromises()
|
||||
await upload(wrapper)
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', { recordVersion: '8', fileId: photo.fileId, idempotencyKey: expect.any(String) })
|
||||
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not offer upload with list permission alone', async () => {
|
||||
|
||||
@@ -7,70 +7,41 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, recoverVehiclePhoto, uploadVehiclePhoto, type BindVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, uploadVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
|
||||
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
|
||||
const preview = ref('')
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
|
||||
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const roles: Array<{ slot: FuelSlot; label: string }> = [{ slot: 'FUEL_START', label: '起始油耗表盘' }, { slot: 'FUEL_END', label: '结束油耗表盘' }]
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { const result = await getVehicleDocuments(number); ensureScope(); if (result.dispatchNo !== number) throw new Error('车辆资料与排车单不一致'); snapshot.value = result }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '车辆资料读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function bind() {
|
||||
const item = pending.value
|
||||
if (!item?.photo || !snapshot.value) return
|
||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||
let result: VehicleDocuments
|
||||
try { result = await bindVehiclePhoto(number, item.slot, item.binding) }
|
||||
catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '图片保存失败')
|
||||
if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !editable.value || !pending.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { ensureScope(); if (!pending.value.photo) { pending.value.photo = await recoverVehiclePhoto(number, pending.value.key); ensureScope(); persist() }; await bind() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function refreshBinding() {
|
||||
if (busy.value || !editable.value || !pending.value?.versionConflict) return
|
||||
await load()
|
||||
if (error.value || !pending.value) return
|
||||
pending.value.binding = undefined; pending.value.versionConflict = false; persist()
|
||||
await recover()
|
||||
}
|
||||
async function upload(slot: FuelSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
|
||||
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
ensureScope()
|
||||
const photo = await uploadVehiclePhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getVehicleDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindVehiclePhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function view(photo: VehiclePhoto) {
|
||||
if (busy.value) return
|
||||
@@ -79,31 +50,35 @@ async function view(photo: VehiclePhoto) {
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (raw) { const value = JSON.parse(raw) as Pending; if (!roles.some(role => role.slot === value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原图片请求记录无法读取'); pending.value = value }
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原图片请求记录无法读取') }
|
||||
if (!corrupted.value) void load()
|
||||
})
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; if (preview.value) URL.revokeObjectURL(preview.value) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
|
||||
<section v-for="role in roles" :key="role.slot" class="density-dialog-section">
|
||||
<h3 class="density-dialog-section__title">{{ role.label }}</h3>
|
||||
<div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div>
|
||||
<span v-if="snapshot && !snapshot.photos.some(item => item.slot === role.slot)">未上传</span>
|
||||
<label v-if="editable" class="photo-upload">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label>
|
||||
</section>
|
||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table fuel-documents-table" aria-label="油耗图片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">图片类别</th><th>文件名称</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="role in roles" :key="role.slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>{{ role.label }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">未上传</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">—</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<img v-if="preview" :src="preview" alt="油耗图片预览" class="photo-preview" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.photo-upload { display: block; margin-top: var(--admin-space-3); }
|
||||
.photo-upload input { margin-left: var(--admin-space-3); }
|
||||
.fuel-documents-table { table-layout: fixed; }
|
||||
.fuel-documents-table td { overflow-wrap: anywhere; }
|
||||
.fuel-documents-table :deep(.el-button > span) { white-space: normal; text-align: left; overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 160px; }
|
||||
.photo-time { width: 190px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
|
||||
.photo-upload.is-disabled input { cursor: not-allowed; }
|
||||
.photo-preview { display: block; max-width: 100%; max-height: 400px; object-fit: contain; }
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import type { DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
|
||||
defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
const maintenance = defineModel<DispatchVehicleMaintenance>('maintenance', { required: true })
|
||||
const plannedDepartureAt = defineModel<string>('plannedDepartureAt', { required: true })
|
||||
const estimatedArrivalAt = defineModel<string>('estimatedArrivalAt', { required: true })
|
||||
const publishLooseboardDemand = defineModel<boolean>('publishLooseboardDemand', { required: true })
|
||||
const tab = ref('info')
|
||||
const deliveryMethods = ['FBA自提', '客户自提', '卡派打托', '卡派地板', '快递', '外配']
|
||||
const pickupMethods = ['一提一卸', '一提多卸']
|
||||
const loadTypes = ['托盘', '地板', '混装']
|
||||
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row])))
|
||||
/** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */
|
||||
function confirmedTime(appointment: DispatchDestinationAppointment) {
|
||||
const fact = appointmentFacts.value.get(appointment.destinationKey)
|
||||
return formatOverseasDateTime(fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,7 +31,7 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
<section class="density-dialog-section" aria-label="本车资源登记">
|
||||
<h3 class="density-dialog-section__title">车辆与司机</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /></el-form-item></td></tr></tbody></table>
|
||||
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车时间登记">
|
||||
<h3 class="density-dialog-section__title">排车时间</h3>
|
||||
@@ -42,10 +47,10 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
<el-form-item label="车队邮箱"><el-input v-model="maintenance.fleetEmail" :disabled="disabled" maxlength="254" placeholder="请输入车队邮箱" /></el-form-item>
|
||||
<el-form-item label="车辆类型"><el-select v-model="maintenance.vehicleType" :disabled="disabled" clearable placeholder="请选择车辆类型"><el-option v-for="value in vehicleTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
|
||||
<el-form-item label="预约号"><el-input v-model="maintenance.appointmentNo" :disabled="disabled" maxlength="128" placeholder="请输入预约号" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="detail.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="publishLooseboardDemand" :disabled="disabled">发布公共散板需求</el-checkbox></el-form-item>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>已确认预约号</th><th>确认送达时间</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
<table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
|
||||
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ confirmedTime(appointment) }}</td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
|
||||
<section class="density-dialog-section" aria-label="车辆维护附件"><h3 class="density-dialog-section__title">附件</h3><slot name="attachments" /></section>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { downloadDriverLicense } from '@/modules/overseas-master-data'
|
||||
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -16,7 +17,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), downloadDriverLicense: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -53,11 +54,53 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('previews the selected master license without treating it as a trip upload and clears it on driver change', async () => {
|
||||
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const { dialog } = await render('EDIT')
|
||||
const fields = dialog.findComponent(DispatchResourceFields)
|
||||
const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } }
|
||||
fields.vm.$emit('update:driver', selected); await flushPromises()
|
||||
const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
|
||||
expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片')
|
||||
expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33')
|
||||
expect(license().props('modelValue')).toEqual([])
|
||||
expect(license().text()).not.toContain('移除引用')
|
||||
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
|
||||
fields.vm.$emit('update:driver', { ...selected, driverId: '23', evidence: { ...selected.evidence, masterLicenseFileId: null } }); await flushPromises()
|
||||
expect(license().find('img').exists()).toBe(false)
|
||||
expect(license().text()).toContain('暂无照片')
|
||||
})
|
||||
it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => {
|
||||
const saved = resources()
|
||||
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: saved })
|
||||
const license = dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
|
||||
expect(license.findAll('img')).toHaveLength(1)
|
||||
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
|
||||
expect(downloadDriverLicense).not.toHaveBeenCalled()
|
||||
})
|
||||
it('brings ten orders at one destination into one appointment input with confirmed time and all shipments', async () => {
|
||||
const initial = detail(), shipmentNumbers = Array.from({ length: 10 }, (_, index) => `SHIP-${index}`)
|
||||
const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: 'ISA-ONE', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers }
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails: shipmentNumbers.map((_, index) => ({ ...initial.orderDetails[0]!, orderNo: `LM-${index}`, customerId: `C-${index}` })), dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } })
|
||||
const table = dialog.get('[aria-label="目的仓预约"]')
|
||||
expect(table.findAll('tbody tr')).toHaveLength(1)
|
||||
expect(table.findAll('input')).toHaveLength(1)
|
||||
expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE')
|
||||
expect(table.findAll('tbody td')[2]!.text()).not.toBe('—')
|
||||
for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment)
|
||||
await table.get('input').setValue('ISA-CHANGED')
|
||||
expect(table.findAll('tbody td')[2]!.text()).toBe('—')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-CHANGED' }] }) }))
|
||||
})
|
||||
it('restores saved maintenance facts and shows associated orders without editable departure facts', async () => {
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-SAVED' }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved } })
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'CONF-REAL' }] }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved }, dispatchInfo: { ...detail().dispatchInfo, fbaAppointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: ['SHIP-REAL'], batchNo: 'AP-1', appointmentNo: 'CONF-REAL', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null }] } })
|
||||
expect((field(dialog, '车队邮箱').get('input').element as HTMLInputElement).value).toBe('fleet@example.test')
|
||||
expect((field(dialog, '预约号').get('input').element as HTMLInputElement).value).toBe('APPT-SAVED')
|
||||
expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('CONF-REAL')
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(1)
|
||||
expect(dialog.text()).not.toContain('关联正式预约')
|
||||
expect(dialog.text()).toContain('关联订单 (1)')
|
||||
const orders = dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!
|
||||
await orders.trigger('click')
|
||||
@@ -67,6 +110,29 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it('edits one number per destination and can save an incomplete second destination', async () => {
|
||||
const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }))
|
||||
const initial = detail()
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails: [...initial.orderDetails, { ...initial.orderDetails[0]!, customerId: 'OTHER', orderNo: 'LM-OTHER' }], dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: destinations } })
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2)
|
||||
await field(dialog, 'ONT8 预约号').get('input').setValue(' ISA-ONT8 ')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-ONT8' }, { destinationKey: 'FBA:LGB8', appointmentNo: null }] }) }))
|
||||
})
|
||||
it('keeps input editable after rejection and adds concurrent destinations when current facts are refreshed', async () => {
|
||||
const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }
|
||||
const initial = detail()
|
||||
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } })
|
||||
await field(dialog, 'ONT8 预约号').get('input').setValue('ISA-KEEP')
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('排车版本已变化', 'A0409', 200))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2, actions, dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment, { ...appointment, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8' }] } }))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(field(dialog, 'ONT8 预约号').get('input').attributes('disabled')).toBeUndefined()
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('ISA-KEEP')
|
||||
expect(field(dialog, 'LGB8 预约号')).toBeDefined()
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -90,13 +156,12 @@ describe('dispatch workflow native layout', () => {
|
||||
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
|
||||
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
|
||||
await field(dialog, '预约号').get('input').setValue(' APPT-NEW ')
|
||||
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', appointmentNo: 'APPT-NEW' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', destinationAppointments: [] }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
})
|
||||
it('saves all three resource photos before submitting review with the refreshed version', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
@@ -128,8 +193,15 @@ describe('dispatch workflow native layout', () => {
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.props('width')).toBe('1100px')
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false)
|
||||
expect(dialog.get('[aria-label="本车审核依据"] h3').text()).toBe('车辆与货物')
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').text()).toContain('联系电话')
|
||||
expect(dialog.get('[aria-label="排车与预约"]').findAll('input')).toHaveLength(0)
|
||||
const photos = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(photos.map(photo => photo.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(photos.every(photo => photo.props('readonly'))).toBe(true)
|
||||
expect(dialog.get('[aria-label="操作原因与意见"] h3').text()).toBe('审核意见')
|
||||
expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
|
||||
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
|
||||
expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
|
||||
@@ -191,6 +263,8 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
|
||||
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
|
||||
expect(dialog.props('confirmLoading')).toBe(false)
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
|
||||
expect(next).toMatchObject({...original,recordVersion:1,idempotencyKey:expect.any(String)})
|
||||
@@ -232,9 +306,9 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
|
||||
})
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('replaces the old %s editor with the smart dispatch entry notice', async action => {
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('keeps the retired %s editor unavailable', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.text()).toContain('货物安排统一在智能排车中修改')
|
||||
expect(dialog.text()).toContain('当前入口不支持调整已建排车货物')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
expect(dialog.findComponent({ name: 'ElForm' }).exists()).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -12,13 +12,13 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string; publishLooseboardDemand: boolean }; detail: DispatchDetail }
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, state: wrapper.vm as unknown as State }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
||||
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
||||
@@ -90,17 +90,17 @@ it('refuses a route that drops an authoritative destination', async () => {
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
||||
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
||||
})
|
||||
it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => {
|
||||
it('reports A0409 and immediately releases the form without a forced readback', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
|
||||
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
wrapper.unmount()
|
||||
@@ -111,7 +111,7 @@ it('retains edited values and the original validation response when the action i
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
||||
expect(state.detail.recordVersion).toBe(7)
|
||||
expect(state.detail.recordVersion).toBe(0)
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
expect(wrapper.text()).toContain('A0400')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
@@ -122,7 +122,7 @@ it('retains edited values and the original validation response when the action i
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => {
|
||||
it('allows another click after rejection even when a later read would fail', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
|
||||
@@ -133,7 +133,7 @@ it('reports a definite rejection to its parent even if the follow-up GET fails a
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(state.form.remark).toBe('保留本次备注')
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ends every failed request and displays a later business rejection without replaying',async()=>{
|
||||
@@ -161,10 +161,10 @@ it('unlocks when a successful write is followed by a failed read',async()=>{
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command after the existing cargo editor is retired', async action => {
|
||||
const { wrapper, state } = await open(action)
|
||||
try {
|
||||
expect(wrapper.text()).toContain('货物安排统一在智能排车中修改')
|
||||
expect(wrapper.text()).toContain('当前入口不支持调整已建排车货物')
|
||||
await state.submit()
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
||||
@@ -178,7 +178,7 @@ it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when open
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/ORIGINAL',action,attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const {wrapper,state}=await open('EDIT')
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
@@ -239,7 +239,7 @@ it('allows a fresh save and review after a failed save without persisting review
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
|
||||
await state.submit(true)
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({recordVersion:1,actions:{SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}})).mockResolvedValueOnce(detail({recordVersion:2}))
|
||||
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>({...command('SUBMIT_REVIEW',body.idempotencyKey),recordVersion:2}))
|
||||
@@ -260,13 +260,23 @@ it('allows a new save-and-review click after review submission fails',async()=>{
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
|
||||
it('persists the explicit publication choice with the existing vehicle save', async () => {
|
||||
it('keeps publication fixed when saving vehicle maintenance', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
expect(state.form.publishLooseboardDemand).toBe(true)
|
||||
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('发布公共散板需求')
|
||||
state.form.publishLooseboardDemand = false
|
||||
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('发布公共散板需求')
|
||||
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, publishLooseboardDemand: false }))
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0 }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('publishLooseboardDemand')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves a cleared unavailable master vehicle as an empty draft resource', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: { mode: 'MASTER'; vehicleId: string | null } }
|
||||
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: null }))
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||||
import DispatchAppointments from './DispatchAppointments.vue'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
@@ -39,20 +40,33 @@ const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: n
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
|
||||
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
|
||||
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
|
||||
const unsavedMasterLicense = computed(() => {
|
||||
const fileId = masterLicenseReference.value, driverId = driver.value.driverId
|
||||
const saved = detail.value?.vehicleInfo.driver
|
||||
if (!fileId || !driverId || saved?.driverId === driverId && saved.evidence?.masterLicenseFileId === fileId) return null
|
||||
return { driverId, fileId }
|
||||
})
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value))
|
||||
const approvalBlock = computed(() => isReview.value && detail.value?.actions[currentAction.value]?.blockCode === 'APPROVAL_BLOCKED' ? detail.value.actions[currentAction.value]?.blockReason : null)
|
||||
const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写'
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
/** 并发加货后补入新目的仓,保留客服已填写的同仓编号。 */
|
||||
function fillDestinationAppointments(value: DispatchDetail, keepInput = false) {
|
||||
const entered = new Map(keepInput ? form.maintenance.destinationAppointments.map(row => [row.destinationKey, row.appointmentNo]) : [])
|
||||
form.maintenance.destinationAppointments = value.dispatchInfo.fbaAppointments.map(row => ({ destinationKey: row.destinationKey, appointmentNo: entered.has(row.destinationKey) ? entered.get(row.destinationKey)! : row.appointmentNo }))
|
||||
}
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||
const cargoEntryMessage = '当前入口不支持调整已建排车货物。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||
const submitting = ref(false)
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
@@ -64,18 +78,19 @@ async function load() {
|
||||
route.value = null; stops.value = []
|
||||
try {
|
||||
if (cargoAction.value) return
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
const value = await api.getDispatch(no)
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
fillDestinationAppointments(value)
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
|
||||
if (!driver.value.evidence) driver.value.evidence = blankDriver().evidence
|
||||
Object.assign(form, { publishLooseboardDemand: value.publishLooseboardDemand, plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
|
||||
Object.assign(form, { plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
|
||||
if (currentOperation.value === 'ROUTE') {
|
||||
const data = await api.getDispatchRoute(no)
|
||||
if (sequence !== generation) return
|
||||
@@ -110,7 +125,7 @@ function payload(): object {
|
||||
case 'MARK_EXCEPTION':
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -153,7 +168,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
error.value = null
|
||||
try {
|
||||
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
if (!pending.value) {
|
||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
|
||||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||
@@ -163,7 +178,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
|
||||
const readCurrent = async (no: string) => {
|
||||
const value = await api.getDispatch(no)
|
||||
if (value.dispatchNo === currentNo.value) detail.value = value
|
||||
if (value.dispatchNo === currentNo.value) { detail.value = value; fillDestinationAppointments(value, true) }
|
||||
return value
|
||||
}
|
||||
let result = await recovery.execute(send, readCurrent)
|
||||
@@ -182,7 +197,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
|
||||
editorRequestKey = ''
|
||||
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
|
||||
if (!(cause instanceof DispatchInputError)) await refreshFacts()
|
||||
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
async function refreshFacts() {
|
||||
@@ -193,6 +208,7 @@ async function refreshFacts() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
fillDestinationAppointments(value, true)
|
||||
if (currentOperation.value === 'ROUTE') {
|
||||
const data = await api.getDispatchRoute(no)
|
||||
if (sequence !== generation) return
|
||||
@@ -211,7 +227,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
@@ -230,21 +246,29 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||
</template>
|
||||
<template v-else-if="vehicleEditor">
|
||||
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
</template>
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
<table class="density-table"><tbody><tr><th>计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td></tr><tr><th>预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th><th>确认送达(北京时间)</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}</td><td>{{ reviewTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据">
|
||||
<h3 class="density-dialog-section__title">车辆与货物</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
|
||||
<tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td><th scope="row">路线</th><td>{{ detail.dispatchInfo.route || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="排车与预约">
|
||||
<h3 class="density-dialog-section__title">排车与预约</h3>
|
||||
<table class="density-table"><tbody><tr><th scope="row">计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||||
<DispatchAppointments :appointments="detail.dispatchInfo.fbaAppointments" :show-shipment-numbers="currentAction !== 'DISPATCH_REVIEW'" />
|
||||
<el-alert v-if="approvalBlock" :title="approvalBlock" type="warning" :closable="false" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
@@ -254,10 +278,13 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -141,23 +141,24 @@ it('receives the native original carton whole into a verified target without inv
|
||||
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
|
||||
it('allows editing after unknown failure and submits a new command', async () => {
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3]))
|
||||
expect(wrapper.text()).toContain('已保留原请求')
|
||||
expect(wrapper.get('[data-test="notes"]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('[data-test="notes"]').setValue('changed')
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(payload.idempotencyKey); expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(expect.objectContaining({ handlingResult: 'changed' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definite stale version and requires a reviewed new submission', async () => {
|
||||
it('keeps a conflict visible and lets the operator refresh voluntarily', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重新读取详情')!.trigger('click'); await flushPromises()
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
|
||||
@@ -246,7 +247,7 @@ it('allows cancellation and ordinary completion with standard audit text and sti
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => {
|
||||
it('closing and reopening blocks only the active request and failures release each subsequent command', async () => {
|
||||
let rejectFirst!: (reason: Error) => void
|
||||
vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
@@ -258,15 +259,15 @@ it('closing and reopening cannot replay an in-flight command or lose a later unk
|
||||
rejectFirst(new Error('R1 outcome unknown')); await flushPromises()
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2))
|
||||
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey)
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(originalKey)
|
||||
await fill(wrapper)
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown'))
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey
|
||||
expect(newKey).not.toBe(originalKey)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey)
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).not.toBe(newKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,13 +22,12 @@ type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity:
|
||||
const receipts = ref<ReceiptInput[]>([]), targets = ref<PalletDto[]>([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
|
||||
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
|
||||
const pendingByScope = reactive(new Map<string, Attempt>())
|
||||
const unknownUploads = reactive(new Set<string>())
|
||||
const savedEvidence = new Map<string, { fileId: string; fileName: string }[]>()
|
||||
const selectedHistory = ref<string[]>([])
|
||||
const inFlight = reactive(new Map<string, string>())
|
||||
const uploadingScopes = reactive(new Set<string>())
|
||||
const pending = computed(() => pendingByScope.get(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value || unknownUploads.has(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value)
|
||||
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
|
||||
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
|
||||
let sequence = 0
|
||||
@@ -91,13 +90,12 @@ async function upload(event: Event) {
|
||||
savedEvidence.set(scope, [...(savedEvidence.get(scope) || []), fileResult])
|
||||
if (current(ticket)) evidence.value.push(fileResult)
|
||||
} catch (cause) {
|
||||
if (!definite(cause)) unknownUploads.add(scope)
|
||||
if (current(ticket)) error.value = definite(cause) ? getApiErrorDetails(cause, '证据上传失败').message : '证据上传结果未知,请先人工核对;不会自动重复上传'
|
||||
if (current(ticket)) error.value = getApiErrorDetails(cause, '证据上传失败,请重新选择文件').message
|
||||
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
|
||||
}
|
||||
async function execute(action: ExceptionAction) {
|
||||
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName)) || unknownUploads.has(identity.value)) return
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
|
||||
if (pending.value && pending.value.action !== action) return
|
||||
if (!pending.value) {
|
||||
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
|
||||
@@ -128,7 +126,7 @@ async function execute(action: ExceptionAction) {
|
||||
const result = await commandException(customer, number, attempt.action, attempt.payload)
|
||||
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
|
||||
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
|
||||
throw new Error('处理结果无法核对,请保留原请求重试')
|
||||
throw new Error('处理结果无法核对,请刷新当前记录')
|
||||
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
|
||||
if (!current(ticket)) return
|
||||
message.value = action === 'complete' && result.status === 'PROCESSING' ? `本批已实收 ${result.restoredPackages} 箱,仍有待收货物` : '操作已保存'
|
||||
@@ -137,9 +135,9 @@ async function execute(action: ExceptionAction) {
|
||||
if (definite(cause) && sameAttempt()) pendingByScope.delete(scope)
|
||||
if (!current(ticket)) return
|
||||
const text = getApiErrorDetails(cause, '处理请求失败').message
|
||||
if (definite(cause)) { responseSequence = sequence + 1; await load(); if (current(responseSequence)) error.value = `${text};已重新读取当前版本,请核对后再操作` }
|
||||
else error.value = `${text};结果未确认,已保留原请求,请仅重试原操作`
|
||||
error.value = text
|
||||
} finally {
|
||||
if (sameAttempt()) pendingByScope.delete(scope)
|
||||
if (inFlight.get(scope) === requestKey) inFlight.delete(scope)
|
||||
if (current(responseSequence)) busy.value = false
|
||||
}
|
||||
@@ -221,12 +219,9 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
</el-form>
|
||||
<p v-if="pending">存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。</p>
|
||||
<p v-if="unknownUploads.has(identity)">证据上传结果未知,需人工核对后继续。</p>
|
||||
<el-button v-if="pending && !detail.order.allowedActions?.includes({ 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[pending.action])" data-test="retry" :disabled="busy" @click="execute(pending.action)">重试原操作</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ pending ? '重试原开始请求' : '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ pending ? '重试原取消请求' : '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
|
||||
it('keeps publication available before cargo selection without extra planning fields', async () => {
|
||||
const { dialog } = await render()
|
||||
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
|
||||
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
|
||||
@@ -213,14 +213,16 @@ describe('new dispatch native layout', () => {
|
||||
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||
await restore(dialog)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
it('creates an unassigned empty dispatch while preserving optional dates and remark through fullscreen', async () => {
|
||||
it('creates an empty dispatch with optional publication preserved through fullscreen', async () => {
|
||||
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
|
||||
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
|
||||
await field(dialog, '备注').get('input').setValue('本趟备注')
|
||||
expect(view(dialog).find('[aria-label="排车计划时间与备注"]').exists()).toBe(false)
|
||||
expect(view(dialog).findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
|
||||
const publication = view(dialog).get('.dispatch-candidate-query input[type="checkbox"]')
|
||||
expect(publication.element).toHaveProperty('checked', false)
|
||||
await publication.setValue(true)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
|
||||
@@ -229,7 +231,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
|
||||
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
|
||||
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
|
||||
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicle: null, driver: null })
|
||||
})
|
||||
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
|
||||
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||
@@ -239,15 +241,14 @@ describe('new dispatch native layout', () => {
|
||||
expect(listVehicles).not.toHaveBeenCalled()
|
||||
expect(listDrivers).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps each group configuration independent and keeps form values editable after conversion read failure', async () => {
|
||||
it('applies publication to all cars and keeps it editable after conversion read failure', async () => {
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
const { wrapper, dialog } = await render(); await restore(dialog)
|
||||
await field(dialog, '备注').get('input').setValue('第一组')
|
||||
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
|
||||
const radios = view(dialog).get('[aria-label="车辆装载"]').findAll('input[type="radio"]')
|
||||
await radios[1]!.setValue(true); await flushPromises()
|
||||
await field(dialog, '备注').get('input').setValue('第二组')
|
||||
await radios[0]!.setValue(true); await flushPromises()
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
|
||||
@@ -256,7 +257,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request.recordVersion).toBe(0)
|
||||
expect(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
|
||||
expect(request.groups.map(group => [group.dispatch.publishLooseboardDemand, group.dispatch.plannedDepartureAt, group.dispatch.estimatedArrivalAt, group.dispatch.remark])).toEqual([[true, null, null, null], [true, null, null, null]])
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
@@ -273,12 +274,12 @@ describe('new dispatch native layout', () => {
|
||||
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
||||
expect(view(dialog).text()).toContain('new-dispatch-request')
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||
expect(field(dialog,'备注').get('input').attributes('disabled')).toBeUndefined()
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -79,7 +79,6 @@ async function render() {
|
||||
return wrapper.getComponent(AppDialog)
|
||||
}
|
||||
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
|
||||
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! }
|
||||
function button(dialog: VueWrapper, label: string) { return view(dialog).findAll('button').find(row => row.text() === label)! }
|
||||
function car(dialog: VueWrapper, index: number) { return view(dialog).get(`article[aria-label="第 ${index} 辆车"]`) }
|
||||
function cargoRow(dialog: VueWrapper, palletNo: string) { return view(dialog).get(`[aria-label="托盘 ${palletNo}"]`) }
|
||||
@@ -128,24 +127,24 @@ describe('three-column cargo planning through visible controls', () => {
|
||||
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
})
|
||||
it('retains the first car cargo, isolates per-car fields and adds later cargo to the car selected again', async () => {
|
||||
it('retains car cargo and the publication choice when switching cars', async () => {
|
||||
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
|
||||
await field(dialog, '备注').get('input').setValue('第一车独立备注')
|
||||
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
|
||||
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
|
||||
expect(car(dialog, 1).text()).toContain('PALLET-1')
|
||||
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
await field(dialog, '备注').get('input').setValue('第二车独立备注'); await join(dialog, 'PALLET-2')
|
||||
await join(dialog, 'PALLET-2')
|
||||
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[1]![0].draftContainerId).toBe('container-2')
|
||||
await chooseCar(dialog, 1)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一车独立备注')
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
|
||||
await join(dialog, 'PALLET-3')
|
||||
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[2]![0].draftContainerId).toBe('container-1')
|
||||
expect(car(dialog, 1).text()).toContain('PALLET-3'); expect(car(dialog, 2).text()).toContain('PALLET-2')
|
||||
await chooseCar(dialog, 2)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第二车独立备注')
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
|
||||
})
|
||||
it('selects the newly added ID even when its response lists the new container first and old container last', async () => {
|
||||
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
|
||||
|
||||
@@ -14,30 +14,29 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('./SmartDispatchExistingCargo.vue', () => ({ default: { name: 'SmartDispatchExistingCargo', emits: ['saved', 'busy'], template: '<div data-existing-cargo />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
|
||||
it('switches to existing cargo inside the same dialog without creating a draft or submitting a new dispatch', async () => {
|
||||
it('opens only new dispatch planning and keeps draft cargo selection available', async () => {
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
await wrapper.getComponent({ name: 'ElDialogContent' }).findAll('button').find(button => button.text() === '调整已有排车货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(dialog.find('[aria-label="智能排车工作方式"]').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain('调整已有排车货物')
|
||||
expect(dialog.findComponent({ name: 'SmartDispatchCargoBoard' }).exists()).toBe(true)
|
||||
expect(dialog.props('showConfirm')).toBe(true)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledOnce()
|
||||
expect(api.createDispatch).not.toHaveBeenCalled()
|
||||
expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
|
||||
wrapper.getComponent({ name: 'SmartDispatchExistingCargo' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s browser requests and opens a fresh workspace',async action=>{
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action,attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
|
||||
expect(wrapper.findComponent({name:'SmartDispatchExistingCargo'}).exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="智能排车工作方式"]').exists()).toBe(false)
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('原请求已保留')
|
||||
expect(wrapper.text()).not.toContain('原请求结果尚待核对');wrapper.unmount()
|
||||
})
|
||||
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
|
||||
@@ -133,13 +132,12 @@ it('keeps planning remarks independent with unassigned resources and permits res
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; form: { remark: string } }
|
||||
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
|
||||
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises()
|
||||
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
|
||||
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
|
||||
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
|
||||
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, null], [null, null, null]])
|
||||
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('clears an old departure request without blocking new empty creation',async()=>{
|
||||
@@ -149,7 +147,7 @@ it('clears an old departure request without blocking new empty creation',async()
|
||||
const state=wrapper.vm as unknown as {mode:string;submit():Promise<void>}
|
||||
state.mode='empty';await flushPromises();await state.submit()
|
||||
expect(api.createDispatch).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull();wrapper.unmount()
|
||||
expect(wrapper.text()).not.toContain('原请求已保留');wrapper.unmount()
|
||||
})
|
||||
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
|
||||
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
|
||||
@@ -178,14 +176,14 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('defaults publication on for every new dispatch and carries opt-out in the atomic draft conversion', async () => {
|
||||
it.each([false, true])('defaults publication off and carries publication %s in the atomic draft conversion', async publish => {
|
||||
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<void>; form: { publishLooseboardDemand: boolean } }
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; publishLooseboardDemand: boolean }
|
||||
state.restoreDraft('server-token'); await flushPromises()
|
||||
expect(state.form.publishLooseboardDemand).toBe(true)
|
||||
state.form.publishLooseboardDemand = false
|
||||
expect(state.publishLooseboardDemand).toBe(false)
|
||||
state.publishLooseboardDemand = publish
|
||||
await state.submit()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: false }) })] }))
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: publish }) })] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -20,7 +20,6 @@ import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, validateCurrentDispatch } from '../dispatchCommandRecovery'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||||
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||||
@@ -30,8 +29,6 @@ const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
|
||||
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[] }
|
||||
const pending = ref<DraftCommand | null>(null)
|
||||
if (owner) sessionStorage.removeItem(storageKey)
|
||||
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
|
||||
const settingsExpanded = ref(false)
|
||||
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
|
||||
@@ -41,17 +38,9 @@ const { candidates, page, size, total } = candidatePage
|
||||
const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
|
||||
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
||||
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string; publishLooseboardDemand: boolean } }>>({})
|
||||
const currentSettings = computed(() => {
|
||||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||||
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', publishLooseboardDemand: true } }
|
||||
return settings[key]!
|
||||
})
|
||||
const form = computed(() => currentSettings.value.form)
|
||||
const publishLooseboardDemand = ref(false)
|
||||
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
|
||||
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
||||
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
|
||||
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || recovery.busy.value)
|
||||
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
|
||||
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
|
||||
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
|
||||
@@ -113,8 +102,7 @@ async function init() {
|
||||
const sequence = ++generation
|
||||
working.value = true; error.value = null
|
||||
try {
|
||||
if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
|
||||
if (workspace.value === 'existing' || !canCreate.value) return
|
||||
if (!canCreate.value) return
|
||||
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
||||
if (sequence !== generation) return
|
||||
warehouses.value = directory; drafts.value = saved
|
||||
@@ -130,13 +118,6 @@ async function init() {
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
function switchWorkspace(value: 'create' | 'existing') {
|
||||
if (workspaceFrozen.value || value === workspace.value || (value === 'create' ? !canCreate.value : !canEditCargo.value)) return
|
||||
workspace.value = value
|
||||
existingBusy.value = false
|
||||
candidatePage.reset()
|
||||
void init()
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
if (sequence !== generation) return
|
||||
const candidateWarehouse = draft.value?.warehouseId
|
||||
@@ -147,12 +128,11 @@ async function loadCandidates(sequence = generation) {
|
||||
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
|
||||
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
|
||||
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
|
||||
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
||||
function creation(cargo: Candidate[]): CreateDispatch {
|
||||
candidatePage.assertCurrent(cargo)
|
||||
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 { 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 } }))) }
|
||||
return { publishLooseboardDemand: publishLooseboardDemand.value, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, 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<boolean> {
|
||||
if (!canCreate.value || loading.value || saving.value || pending.value) return false
|
||||
@@ -178,7 +158,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
||||
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
|
||||
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
|
||||
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
|
||||
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[], row.draftContainerId) }
|
||||
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[]) }
|
||||
})
|
||||
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
|
||||
body = { ...common, groups } satisfies ConvertDraft
|
||||
@@ -226,20 +206,9 @@ async function retryDraft() {
|
||||
if (draft.value) warehouseId.value = draft.value.warehouseId
|
||||
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
|
||||
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
|
||||
} catch (cause) {
|
||||
// 请求失败后读取服务器最新草稿,保留本地时间和备注,后续点击按当前版本重新提交。
|
||||
if (command.body.draftToken) {
|
||||
try {
|
||||
const saved = await api.listDispatchDrafts()
|
||||
drafts.value = saved; draft.value = saved.find(row => row.draftToken === command.body.draftToken) || null
|
||||
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
|
||||
} catch { /* 刷新失败只保留原错误,不锁住其他操作。 */ }
|
||||
}
|
||||
throw cause
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (workspace.value === 'existing') return
|
||||
if (!canCreate.value || preparing.value || saving.value || loading.value) return
|
||||
error.value = null
|
||||
try {
|
||||
@@ -250,21 +219,15 @@ async function submit() {
|
||||
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
|
||||
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
|
||||
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
|
||||
</div>
|
||||
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-dialog-sections dispatch-workspace">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
<div class="dispatch-candidate-query">
|
||||
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
|
||||
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button><el-checkbox v-model="publishLooseboardDemand" :disabled="frozen || loading">发布公共需求</el-checkbox></div>
|
||||
</section>
|
||||
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
|
||||
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
|
||||
@@ -289,18 +252,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
</template>
|
||||
</SmartDispatchCargoBoard>
|
||||
</template>
|
||||
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
||||
<section class="density-dialog-section" aria-label="排车计划时间与备注">
|
||||
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
||||
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="散板需求"><el-checkbox v-model="form.publishLooseboardDemand" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)">发布公共散板需求</el-checkbox></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
@@ -318,8 +270,6 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-content) { min-height: 0; max-height: 100%; overflow-y: auto; }
|
||||
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-list) { max-height: none; overflow: visible; }
|
||||
|
||||
.dispatch-create-table { table-layout: fixed; }
|
||||
.dispatch-create-table td:not([colspan]) { width: 50%; }
|
||||
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
|
||||
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail, listRow, page, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { DispatchCargoItem, DispatchDetail, DispatchItems } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '', allowed: true }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => identity.allowed }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), listDispatchCandidates: vi.fn(), addDispatchCargo: vi.fn(), removeDispatchItem: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
const current = (overrides: Partial<DispatchDetail> = {}) => detail({ taskType: 'DELIVERY', actions: { ADD_CARGO: allowed, REMOVE_CARGO: allowed }, ...overrides })
|
||||
function cargo(overrides: Partial<DispatchCargoItem> = {}): DispatchCargoItem {
|
||||
const source = candidate()
|
||||
return { ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: allowed, ...overrides }
|
||||
}
|
||||
const manifest = (overrides: Partial<DispatchItems> = {}): DispatchItems => ({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [cargo()], ...overrides })
|
||||
function button(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(row => row.text() === text); if (!found) throw new Error(`Missing button ${text}`); return found }
|
||||
async function open(dispatchNo: string | null = 'PC/1') {
|
||||
const wrapper = mount(SmartDispatchExistingCargo, { props: { dispatchNo: dispatchNo ?? undefined }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' })]))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current())
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest())
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
|
||||
})
|
||||
|
||||
it('keeps both searches in one panel and the manifest inside the shared board column', async () => {
|
||||
const wrapper = await open()
|
||||
const search = wrapper.get('[aria-label="已有排车查询"]')
|
||||
expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true)
|
||||
expect(search.text()).not.toContain('查询排车')
|
||||
expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true)
|
||||
expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0)
|
||||
const column = wrapper.get('[aria-label="车辆装载"]')
|
||||
expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1')
|
||||
expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3)
|
||||
expect(column.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
|
||||
return command('ADD_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await add(wrapper)
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargo: [first!.selection, second!.selection] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(api.getDispatchItems).toHaveBeenCalledTimes(3)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(3)
|
||||
expect(wrapper.find('[aria-label="当前排车货物"]').text()).toContain('真实已拣 0 箱')
|
||||
expect(wrapper.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it.each(['complete', 'missing', 'changed'] as const)('reads appointments of a hidden second group source: %s', async scenario => {
|
||||
const first = candidate()
|
||||
const second = candidate({ sourceKey: 'source-2', customerId: 'C2', selection: { ...first.selection, planPalletId: '12' }, appointmentBatches: [{ batchKey: 'B2', batchNo: 'AP2', customerId: 'C2', recordVersion: '9007199254740993', sources: [{ sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }] })
|
||||
const group = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['source-1', 'source-2'], packageCount: 4 }] }
|
||||
first.selectionGroup = group; second.selectionGroup = group
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size !== 100 ? [first] : query.page === 1 ? [first] : scenario === 'missing' ? [] : [{ ...second, selectionGroup: scenario === 'changed' ? { ...group, groupVersion: 'new-composition' } : group }], page: query.page!, size: query.size!, total: 101, pages: 2, complete: true, snapshotAt: '' }))
|
||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
if (scenario === 'complete') {
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledWith({ warehouseId: '321', warehouseTab: 'ALL', page: 2, size: 100 })
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection], batchAllocations: [{ planPalletId: '12', batchKey: 'B2', customerId: 'C2', recordVersion: '9007199254740993', sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }))
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
} else {
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(scenario === 'missing' ? '同托货物及预约资料未完整返回' : '选货组组成已变化')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation version %s in additions', async recordVersion => {
|
||||
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }] })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
|
||||
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
|
||||
const wrapper = await open()
|
||||
await button(wrapper, '移除货物').trigger('click')
|
||||
expect(button(wrapper, '确认移除').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('textarea[aria-label="移除原因"]').setValue('本车计划调整')
|
||||
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
|
||||
return command('REMOVE_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await button(wrapper, '确认移除').trigger('click'); await flushPromises()
|
||||
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' }))
|
||||
expect(wrapper.text()).toContain('本车暂无货物')
|
||||
})
|
||||
|
||||
it('rejects an addition whose authoritative appointment version is missing', async () => {
|
||||
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('预约批次版本缺失或无效')
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a candidate page from another warehouse before exposing selectable cargo', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [candidate({ warehouseId: 'OTHER' })], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
const wrapper = await open()
|
||||
expect(wrapper.text()).toContain('候选起运仓或来源版本不完整')
|
||||
expect(wrapper.findAll('button').some(row => row.text() === '加入这辆车')).toBe(false)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['picked', 'item-gate', 'shared-pallet'] as const)('locks removal for %s instead of changing real picking facts', async scenario => {
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ items: scenario === 'shared-pallet' ? [cargo(), cargo({ dispatchItemId: 'other', sourceKey: 'other' })] : [cargo(scenario === 'picked' ? { pickedPackages: 1 } : { remove: { allowed: false, blockCode: 'LOCK', blockReason: '正在作业' } })] }))
|
||||
const wrapper = await open()
|
||||
for (const row of wrapper.findAll('button').filter(row => row.text() === '移除货物')) expect(row.attributes('disabled')).toBeDefined()
|
||||
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
||||
if (scenario === 'shared-pallet') expect(wrapper.text()).toContain('同一实物托盘包含多个来源')
|
||||
})
|
||||
|
||||
it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as const)('fails closed when %s blocks editing', async scenario => {
|
||||
if (scenario === 'loading') { vi.mocked(api.getDispatch).mockResolvedValue(current({ stage: 'LOADING' })); vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ stage: 'LOADING' })) }
|
||||
if (scenario === 'permission') identity.allowed = false
|
||||
if (scenario === 'task-type') vi.mocked(api.getDispatch).mockResolvedValue(current({ taskType: 'PICKUP' }))
|
||||
if (scenario === 'version') vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 2 }))
|
||||
if (scenario === 'read-failure') vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
|
||||
const wrapper = await open()
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('button').filter(row => row.text() === '移除货物').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
if (scenario === 'version') expect(wrapper.text()).toContain('一致版本')
|
||||
})
|
||||
|
||||
it('unlocks a failed removal and accepts an updated reason on a fresh request',async()=>{
|
||||
const wrapper=await open()
|
||||
await button(wrapper,'移除货物').trigger('click');await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
|
||||
vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown result'))
|
||||
await button(wrapper,'确认移除').trigger('click');await flushPromises()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect((wrapper.get('textarea[aria-label="移除原因"]').element as HTMLTextAreaElement).value).toBe('原移除原因')
|
||||
await wrapper.get('textarea[aria-label="移除原因"]').setValue('重新安排')
|
||||
vi.mocked(api.removeDispatchItem).mockImplementationOnce(async(_,__,body)=>command('REMOVE_CARGO',body.idempotencyKey))
|
||||
await button(wrapper,'确认移除').trigger('click');await flushPromises()
|
||||
const calls=vi.mocked(api.removeDispatchItem).mock.calls
|
||||
expect(calls).toHaveLength(2);expect(calls[1]![2].reason).toBe('重新安排')
|
||||
expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unlocks a completed request when its follow-up manifest read fails', async () => {
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
|
||||
return command('ADD_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await add(wrapper)
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
|
||||
await button(wrapper, '刷新本车').trigger('click'); await flushPromises()
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a late previous dispatch read overwrite the selected dispatch', async () => {
|
||||
let resolve!: (value: DispatchDetail) => void
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' }), listRow({ dispatchNo: 'PC/2', taskType: 'DELIVERY' })]))
|
||||
vi.mocked(api.getDispatch).mockImplementation(no => no === 'PC/1' ? new Promise(done => { resolve = done }) : Promise.resolve(current({ dispatchNo: 'PC/2' })))
|
||||
vi.mocked(api.getDispatchItems).mockImplementation(async no => manifest({ dispatchNo: no }))
|
||||
const wrapper = await open()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
|
||||
resolve(current()); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1')
|
||||
})
|
||||
|
||||
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
|
||||
const wrapper = await open(null)
|
||||
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
|
||||
const picker = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(picker.props('remote')).toBe(true)
|
||||
await picker.get('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
await picker.props('remoteMethod')('PC2026'); await flushPromises()
|
||||
await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises()
|
||||
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { useDispatchCandidates } from '../useDispatchCandidates'
|
||||
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import { canDispatchAction } from '../dispatchPresentation'
|
||||
import { DispatchInputError, expandSelectionGroups, requireBatchVersions, requireSelectionGroup, requireVersion, selectionIdentity } from '../dispatchContracts'
|
||||
import type { AddCargo, Candidate, DispatchCargoItem, DispatchDetail, DispatchItems, DispatchListRow, ReasonCommand } from '../dispatchContracts'
|
||||
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||
|
||||
const props = defineProps<{ dispatchNo?: string }>()
|
||||
const emit = defineEmits<{ saved: []; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const { pending, busy } = recovery
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error)
|
||||
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
|
||||
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
|
||||
const queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
|
||||
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
|
||||
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
|
||||
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
|
||||
const removing = ref<DispatchCargoItem | null>(null), reason = ref('')
|
||||
let generation = 0, listGeneration = 0, alive = true
|
||||
const cargoPending = computed(() => pending.value?.action === 'ADD_CARGO' || pending.value?.action === 'REMOVE_CARGO')
|
||||
const frozen = computed(() => busy.value || preparing.value || Boolean(pending.value))
|
||||
const loading = computed(() => reading.value || candidatePage.loading.value)
|
||||
const editable = computed(() => !stale.value && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.taskType === 'DELIVERY')
|
||||
const canAdd = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'ADD_CARGO', access.can))
|
||||
const canRemove = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'REMOVE_CARGO', access.can))
|
||||
const locked = computed(() => frozen.value || loading.value)
|
||||
const equalVersion = (left: unknown, right: unknown) => String(requireVersion(left)).replace(/^0+(?=\d)/, '') === String(requireVersion(right)).replace(/^0+(?=\d)/, '')
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback)
|
||||
}
|
||||
async function loadList() {
|
||||
const sequence = ++listGeneration
|
||||
listing.value = true
|
||||
try {
|
||||
const result = await api.listDispatches({ page: page.value, size: size.value, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: queriedKeyword.value || undefined })
|
||||
if (!alive || sequence !== listGeneration) return
|
||||
if (result.page !== page.value || result.size !== size.value) throw new DispatchInputError('排车列表页码不一致,请重新查询')
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
|
||||
finally { if (alive && sequence === listGeneration) listing.value = false }
|
||||
}
|
||||
async function searchDispatches(value: string) {
|
||||
if (frozen.value) return
|
||||
queriedKeyword.value = value.trim(); page.value = 1
|
||||
await loadList()
|
||||
}
|
||||
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
|
||||
async function readFacts(no: string) {
|
||||
const [current, cargo] = await Promise.all([api.getDispatch(no), api.getDispatchItems(no)])
|
||||
if (current.dispatchNo !== no || cargo.dispatchNo !== no || !equalVersion(current.recordVersion, cargo.recordVersion) || current.stage !== cargo.stage || current.status !== cargo.status) {
|
||||
throw new DispatchInputError('排车与货物清单已变化,请重新读取一致版本后操作')
|
||||
}
|
||||
return { current, cargo }
|
||||
}
|
||||
async function loadCandidates() {
|
||||
if (!warehouseId.value || !detail.value || !canAdd.value || frozen.value) return
|
||||
await candidatePage.load({ warehouseId: warehouseId.value, warehouseTab: 'ALL', orderNo: queriedCargoKeyword.value || undefined, page: cargoPage.value, size: cargoSize.value })
|
||||
}
|
||||
async function selectDispatch(no: string, row?: DispatchListRow) {
|
||||
if (!no || busy.value || pending.value && (!cargoPending.value || pending.value.dispatchNo !== no)) return
|
||||
const sequence = ++generation
|
||||
selectedNo.value = no; reading.value = true; stale.value = true; error.value = null
|
||||
detail.value = null; items.value = null; removing.value = null; reason.value = ''
|
||||
warehouseId.value = ''; warehouseName.value = ''; candidatePage.reset()
|
||||
try {
|
||||
const origin = row || (await api.listDispatches({ page: 1, size: 10, dispatchNo: no, status: 'ALL' })).records.find(value => value.dispatchNo === no)
|
||||
if (!alive || sequence !== generation) return
|
||||
if (!origin || origin.dispatchNo !== no || !origin.warehouseId) throw new DispatchInputError('排车起运仓未完整返回,请重新读取')
|
||||
const facts = await readFacts(no)
|
||||
if (!alive || sequence !== generation) return
|
||||
warehouseId.value = origin.warehouseId; warehouseName.value = origin.warehouseName || origin.warehouseCode || '—'
|
||||
detail.value = facts.current; items.value = facts.cargo; stale.value = false
|
||||
if (!pending.value) await loadCandidates()
|
||||
} catch (cause) { if (alive && sequence === generation) report(cause, '排车货物读取失败') }
|
||||
finally { if (alive && sequence === generation) reading.value = false }
|
||||
}
|
||||
async function searchCargo() {
|
||||
if (locked.value) return
|
||||
queriedCargoKeyword.value = cargoKeyword.value.trim(); cargoPage.value = 1
|
||||
await loadCandidates()
|
||||
}
|
||||
async function changeCargoPage(value: number) { if (!locked.value) { cargoPage.value = value; await loadCandidates() } }
|
||||
async function changeCargoSize(value: number) { if (!locked.value) { cargoSize.value = value; cargoPage.value = 1; await loadCandidates() } }
|
||||
async function completeGroup(row: Candidate): Promise<Candidate[]> {
|
||||
const group = requireSelectionGroup(row)
|
||||
const found = new Map<string, Candidate>()
|
||||
function collect(values: Candidate[]) {
|
||||
for (const value of values) {
|
||||
const source = group.sources.find(source => source.sourceKey === value.sourceKey)
|
||||
if (!source) continue
|
||||
if (value.warehouseId !== warehouseId.value || value.customerId !== source.customerId || value.sourceVersion !== source.selection.sourceVersion || selectionIdentity(value.selection) !== selectionIdentity(source.selection) || value.selectionGroup.groupKey !== group.groupKey || value.selectionGroup.groupVersion !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请刷新后重试')
|
||||
requireSelectionGroup(value)
|
||||
found.set(value.sourceKey, value)
|
||||
}
|
||||
}
|
||||
collect(candidates.value)
|
||||
// A filtered page may hide another source on this physical pallet, including its appointments.
|
||||
for (let next = 1; found.size < group.sources.length; next++) {
|
||||
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.value, warehouseTab: 'ALL', page: next, size: 100 })
|
||||
if (result.page !== next || result.size !== 100) throw new DispatchInputError('货物来源页码不一致,请重新读取')
|
||||
collect(result.candidates)
|
||||
if (next >= result.pages || result.candidates.length === 0) break
|
||||
}
|
||||
if (found.size !== group.sources.length) throw new DispatchInputError('同托货物及预约资料未完整返回,请刷新后重试')
|
||||
return group.sources.map(source => found.get(source.sourceKey)!)
|
||||
}
|
||||
function buildAddition(row: Candidate, sources: Candidate[]): AddCargo {
|
||||
if (!canAdd.value || !detail.value || !candidates.value.includes(row)) throw new DispatchInputError('当前货物或排车不允许调整,请刷新核对')
|
||||
candidatePage.assertCurrent(sources); requireBatchVersions(sources.flatMap(source => source.appointmentBatches))
|
||||
if (sources.some(source => source.warehouseId !== warehouseId.value || source.blockReasons.length || !source.sourceVersion || source.sourceVersion !== source.selection.sourceVersion || source.appointmentBatches.some(batch => batch.customerId !== source.customerId))) throw new DispatchInputError('请选择同仓、来源完整且预约客户一致的货物')
|
||||
return {
|
||||
recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID(), cargo: expandSelectionGroups(sources),
|
||||
batchAllocations: sources.flatMap(member => member.appointmentBatches.flatMap(batch => batch.sources.map(source => {
|
||||
if (!member.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
||||
return { planPalletId: member.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion }
|
||||
}))),
|
||||
}
|
||||
}
|
||||
async function addCargo(row: Candidate) {
|
||||
if (locked.value) return
|
||||
error.value = null; preparing.value = true
|
||||
const sequence = generation
|
||||
try {
|
||||
const sources = await completeGroup(row)
|
||||
if (!alive || sequence !== generation) return
|
||||
recovery.begin(selectedNo.value, 'ADD_CARGO', buildAddition(row, sources)); preparing.value = false; await execute()
|
||||
}
|
||||
catch (cause) { report(cause, '货物未加入,请核对来源') }
|
||||
finally { preparing.value = false }
|
||||
}
|
||||
function removable(row: DispatchCargoItem) {
|
||||
return canRemove.value && row.remove.allowed === true && row.pickedPackages === 0 && !sharedPallet(row) && Boolean(row.dispatchItemId) && items.value?.items.includes(row)
|
||||
}
|
||||
function sharedPallet(row: DispatchCargoItem) { return Boolean(row.warehousePalletId && (items.value?.items.filter(item => item.warehousePalletId === row.warehousePalletId).length || 0) > 1) }
|
||||
function chooseRemoval(row: DispatchCargoItem) {
|
||||
if (locked.value || !removable(row)) return
|
||||
removing.value = row; reason.value = ''
|
||||
}
|
||||
async function removeCargo() {
|
||||
if (locked.value) return
|
||||
error.value = null
|
||||
try {
|
||||
const row = removing.value
|
||||
if (!row || !removable(row) || !reason.value.trim() || !items.value) throw new DispatchInputError('请选择允许移除的未扫码货物并填写原因')
|
||||
const body: ReasonCommand = { recordVersion: requireVersion(items.value.recordVersion), idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() }
|
||||
recovery.begin(selectedNo.value, 'REMOVE_CARGO', body, { itemId: row.dispatchItemId })
|
||||
await execute()
|
||||
} catch (cause) { report(cause, '货物未移除,请核对当前作业情况') }
|
||||
}
|
||||
function send(command: PendingDispatchCommand) {
|
||||
if (!access.can('platform:overseas-warehouse:dispatch:edit')) throw new DispatchInputError('当前账号没有调整排车货物权限')
|
||||
if (command.action === 'ADD_CARGO') return api.addDispatchCargo(command.dispatchNo, command.body as unknown as AddCargo)
|
||||
if (command.action === 'REMOVE_CARGO' && command.itemId) return api.removeDispatchItem(command.dispatchNo, command.itemId, command.body as unknown as ReasonCommand)
|
||||
throw new DispatchInputError('请回到原操作入口核对该请求')
|
||||
}
|
||||
async function execute() {
|
||||
if (busy.value || !cargoPending.value) return
|
||||
const no = pending.value!.dispatchNo
|
||||
error.value = null
|
||||
try {
|
||||
const result = await recovery.execute(send, async target => {
|
||||
const facts = await readFacts(target)
|
||||
return facts.current
|
||||
})
|
||||
if (!result || !alive) return
|
||||
ElMessage.success(result.replayed ? '原货物调整结果已核对' : '本车货物已更新,请重新核对审核')
|
||||
emit('saved')
|
||||
await selectDispatch(no)
|
||||
await loadList()
|
||||
} catch (cause) {
|
||||
if (!alive) return
|
||||
stale.value = true
|
||||
report(cause, '本次货物调整失败,请刷新当前资料后重试')
|
||||
const failure = error.value
|
||||
try {
|
||||
const facts = await readFacts(no)
|
||||
if (!alive || selectedNo.value !== no) return
|
||||
detail.value = facts.current; items.value = facts.cargo; stale.value = false
|
||||
if (removing.value && !facts.cargo.items.some(row => row.dispatchItemId === removing.value?.dispatchItemId)) removing.value = null
|
||||
await loadCandidates()
|
||||
} catch { /* 读取失败保留原业务错误,仍允许切换排车或手动刷新。 */ }
|
||||
finally { error.value = failure }
|
||||
}
|
||||
}
|
||||
async function retry() { if (cargoPending.value) await execute(); else if (selectedNo.value) await selectDispatch(selectedNo.value); else await loadList() }
|
||||
watch(() => [loading.value, listing.value, busy.value, preparing.value], () => emit('busy', loading.value || listing.value || busy.value || preparing.value), { immediate: true })
|
||||
watch(() => props.dispatchNo, no => {
|
||||
const target = cargoPending.value ? pending.value!.dispatchNo : no
|
||||
if (target) void selectDispatch(target)
|
||||
}, { immediate: true })
|
||||
void loadList()
|
||||
onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidatePage.reset(); emit('busy', false) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="existing-cargo density-dialog-sections">
|
||||
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
|
||||
<div class="existing-cargo-query">
|
||||
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="输入单号搜索并选择排车" filterable remote :remote-method="searchDispatches" :loading="listing" :disabled="frozen" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
|
||||
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
|
||||
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
|
||||
<template #footer><el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" /></template>
|
||||
</el-select>
|
||||
|
||||
<template v-if="selectedNo"><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></template>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<template v-if="selectedNo">
|
||||
|
||||
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
|
||||
<p v-else-if="detail && !canAdd" class="density-dialog-note">{{ detail.actions.ADD_CARGO?.blockReason || '当前状态或权限不允许添加货物' }}</p>
|
||||
<SmartDispatchCargoBoard :candidates="candidates" :draft="null" :active-container-id="selectedNo" :capacity="0" :disabled="locked || !canAdd" :warehouse-selected="Boolean(warehouseId)" :page="cargoPage" :size="cargoSize" :total="cargoTotal" @add="addCargo" @change-page="changeCargoPage" @change-size="changeCargoSize">
|
||||
<template #vehicle-heading>
|
||||
<h3 class="existing-cargo-title">本车货物 · {{ selectedNo }} · {{ warehouseName || '—' }}</h3>
|
||||
<InlineHelp title="本车货物调整说明"><p>加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p></InlineHelp>
|
||||
</template>
|
||||
<template #vehicles="{ dragOver, drop, dropTarget }">
|
||||
<section class="existing-cargo-manifest" :class="{ 'is-drop-target': dropTarget === selectedNo }" aria-label="当前排车货物" @dragover="dragOver($event, selectedNo)" @drop="drop($event, selectedNo)">
|
||||
<p v-if="!items" class="density-dialog-note">请读取完整货物清单</p>
|
||||
<p v-else-if="!items.items.length" class="density-dialog-note">本车暂无货物,可从左侧选择托盘加入。</p>
|
||||
<div v-for="row in items?.items || []" :key="row.dispatchItemId" class="existing-cargo-item">
|
||||
<div><strong>{{ row.palletNo || row.inboundCode || row.orderNo || row.relabelNo || '—' }}</strong><p>{{ row.customerName || '—' }} · {{ row.orderNo || row.inboundCode || row.relabelNo || '—' }}</p><p>{{ row.scheduledPackages }} 箱 · 真实已拣 {{ row.pickedPackages }} 箱 · {{ row.destination.warehouseCode || row.destination.address || '—' }}</p><small v-if="!removable(row)">{{ sharedPallet(row) ? '同一实物托盘包含多个来源,不能单独移除此项' : row.remove.blockReason || detail?.actions.REMOVE_CARGO?.blockReason || '当前状态或权限不允许移除' }}</small></div>
|
||||
<el-button link type="danger" :disabled="locked || !removable(row)" @click="chooseRemoval(row)">移除货物</el-button>
|
||||
</div>
|
||||
<div v-if="removing" class="existing-cargo-removal" aria-label="确认移除本车货物"><strong>移除 {{ removing.palletNo || removing.orderNo || removing.inboundCode || '所选货物' }}</strong><el-input v-model="reason" aria-label="移除原因" placeholder="请填写移除原因(必填)" type="textarea" :disabled="locked" /><el-button type="danger" :disabled="locked || !reason.trim() || !removable(removing)" @click="removeCargo">确认移除</el-button><el-button :disabled="locked" @click="removing = null">取消移除</el-button></div>
|
||||
</section>
|
||||
</template>
|
||||
</SmartDispatchCargoBoard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.existing-cargo { margin-top: var(--admin-space-5); }
|
||||
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); }
|
||||
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.existing-cargo-query > .el-button { margin-left: 0; }
|
||||
.existing-cargo-query > .el-input { width: 240px; }
|
||||
.existing-cargo-query > .el-select { width: 350px; }
|
||||
.existing-cargo-manifest { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); padding: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
|
||||
.existing-cargo-manifest.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
|
||||
.existing-cargo-title { margin: 0; font-size: var(--admin-font-section); overflow-wrap: anywhere; }
|
||||
.existing-cargo-item { display: flex; gap: var(--admin-space-3); justify-content: space-between; padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
|
||||
.existing-cargo-item > div { min-width: 0; }
|
||||
.existing-cargo-item > .el-button { flex: none; }
|
||||
.existing-cargo-item p { margin: var(--admin-space-2) 0; }
|
||||
.existing-cargo-item small { color: var(--cc-muted); }
|
||||
.existing-cargo-removal { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
|
||||
</style>
|
||||
@@ -85,9 +85,8 @@ async function assign() {
|
||||
busy.value = true; error.value = null
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机', ...(detail.value?.taskType === 'PICKUP' ? { dispatchRecordVersion: detail.value.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
|
||||
finally { busy.value = false }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value; busy.value = true; error.value = null
|
||||
@@ -96,8 +95,8 @@ async function execute() {
|
||||
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
|
||||
else await commandTransport(props.dispatchNo, request.suffix, request.body)
|
||||
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
} catch (cause) { if (cause instanceof ApiResponseError) { pending.value = null; await load() }; error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { busy.value = false }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
@@ -121,7 +120,7 @@ async function upload(event: Event, pickup: boolean) {
|
||||
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
|
||||
finally { busy.value = false; input.value = '' }
|
||||
finally { pending.value = null; busy.value = false; input.value = '' }
|
||||
}
|
||||
async function picked() {
|
||||
if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return
|
||||
@@ -134,12 +133,12 @@ async function release() {
|
||||
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
|
||||
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
|
||||
}
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提交,确认离开?'))
|
||||
</script>
|
||||
<template>
|
||||
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending" :loading="busy" @click="execute">核对原请求</el-button>
|
||||
|
||||
<template v-if="detail && trip">
|
||||
<header class="transport-toolbar">
|
||||
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
|
||||
|
||||
@@ -27,7 +27,6 @@ async function load() {
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) { selected.value = []; truck.value = ''; warehouse.value = ''; pending.value = null; void load() } else sequence++ })
|
||||
/** 结果未知时保留相同原请求,禁止重复选择来源另建单。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value) {
|
||||
@@ -49,12 +48,12 @@ async function save() {
|
||||
pending.value = null; emit('created'); emit('update:modelValue', false); ElMessage.success(`已生成排车单 ${detail.dispatchNo}`)
|
||||
await router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: detail.dispatchNo } })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '安排排车未完成'); if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="type === 'PICKUP' ? '安排到仓取货' : '安排本土卡车'" width="1100px" :close-on-click-modal="!busy && !pending" :close-on-press-escape="!busy && !pending" :show-close="!busy && !pending" @update:model-value="emit('update:modelValue', $event)">
|
||||
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="pending ? save() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form" :disabled="busy || !!pending" v-loading="loading">
|
||||
<template v-if="type === 'PICKUP'">
|
||||
<p>审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。</p>
|
||||
@@ -67,6 +66,6 @@ async function save() {
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="预计司机任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ pending ? '按原请求重试' : '生成排车单' }}</el-button></template>
|
||||
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ '生成排车单' }}</el-button></template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -23,41 +23,41 @@ describe('transfer movement UI', () => {
|
||||
can.mockImplementation(code => code !== 'platform:overseas-warehouse:warehouse:list')
|
||||
const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([
|
||||
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: false, canReselect: true },
|
||||
{ cause: new Error('response lost'), recorded: false, canReselect: false },
|
||||
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: true, canReselect: false },
|
||||
])('allows target correction only after definite rejection plus authoritative no-command readback: $canReselect', async ({ cause, recorded, canReselect }) => {
|
||||
const body = { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'original-key' }
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: { type: 'receipt', body } }))
|
||||
const fresh = detail('OUTBOUNDED')
|
||||
if (recorded) fresh.receiptCommands = [{ sourceInventoryPositionId: 'hidden-source', status: 'PREPARED', requestHash: 'hash', request: body, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }]
|
||||
vi.mocked(receiveWarehouseTransfer).mockRejectedValue(cause); vi.mocked(getWarehouseTransfer).mockResolvedValue(fresh)
|
||||
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('重新选择目标')
|
||||
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text().includes('重新选择目标')).toBe(canReselect)
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending.body).toEqual(body)
|
||||
if (canReselect) {
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] })
|
||||
await button(wrapper, '重新选择目标').trigger('click'); await flushPromises()
|
||||
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending).toBeNull()
|
||||
expect(wrapper.text()).toContain('上次目标选择被拒绝:目标不可用')
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
it.each(['{bad', JSON.stringify({pending:{type:'receipt',body:{idempotencyKey:'old'}}})])('ignores stored failed receipts without blocking current selection', async stored => {
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', stored)
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] })
|
||||
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[aria-label="目标仓实收选择"]').exists()).toBe(true)
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled(); expect(wrapper.text()).not.toContain('重试原操作')
|
||||
})
|
||||
it('recovers a reserved original server command in a fresh browser and never queries arbitrary occupied resources', async () => {
|
||||
it.each(['PREPARED', 'RESERVED'] as const)('continues a %s source from current server state and unlocks other sources after failure', async status => {
|
||||
const command: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'hidden-source', status: 'RESERVED', requestHash: 'server-hash', request: { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'server-original' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }
|
||||
command.status = status
|
||||
const d = detail('OUTBOUNDED'); d.receiptCommands = [command]
|
||||
d.allocations!.push({ ...d.allocations![0]!, inventoryPositionId: 'other-source', inboundCode: 'IN-2' })
|
||||
const completed = detail('RECEIVED'); completed.allocations![0]!.status = 'RECEIVED'; completed.receiptCommands = [{ ...command, status: 'COMPLETED' }]
|
||||
vi.mocked(receiveWarehouseTransfer).mockResolvedValue(completed)
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(d)
|
||||
vi.mocked(receiveWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(completed)
|
||||
const wrapper = render(d); await flushPromises()
|
||||
expect(wrapper.text()).toContain('从本票服务端记录恢复'); expect(wrapper.text()).not.toContain('登记目标仓实收')
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('重试原操作'); expect(wrapper.text()).toContain('登记目标仓实收')
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer', 'WT-1', command.request)
|
||||
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'RECEIVED' })
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(button(wrapper, '登记目标仓实收').attributes('disabled')).toBeUndefined()
|
||||
expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined()
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('updated')?.at(-1)?.[0]).toEqual(completed)
|
||||
// 服务端已完成时只展示最新结果,不再次提交。
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(completed)
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(listPallets).not.toHaveBeenCalled(); expect(getPallet).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('hidden-source')
|
||||
})
|
||||
it('does not use a browser remembered occupied target when server receipt history is missing', async () => {
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: null, previous: { warehouse: { id: 'OTHER' }, pallet: { id: 'OTHER', status: 'OCCUPIED' } } }))
|
||||
@@ -79,20 +79,18 @@ describe('transfer movement UI', () => {
|
||||
expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined()
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled()
|
||||
})
|
||||
it('requires permission and explicit actual-outbound confirmation; unknown outcome survives remount with same body', async () => {
|
||||
it('requires outbound permission and releases a failed confirmation for a fresh click', async () => {
|
||||
vi.mocked(outboundWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('OUTBOUNDED'))
|
||||
const first = render(); await button(first, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(first, '确认实际出库').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(outboundWarehouseTransfer).mock.calls[0]))
|
||||
expect(first.text()).toContain('原操作内容已保留'); first.unmount()
|
||||
const second = render(detail('OUTBOUNDED')); await flushPromises()
|
||||
expect(second.text()).not.toContain('hidden-source'); expect(second.text()).toContain('重试原操作')
|
||||
await button(second, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
expect(second.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' })
|
||||
const wrapper = render(); await button(wrapper, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(wrapper, '确认实际出库').trigger('click'); await flushPromises()
|
||||
expect(button(wrapper, '确认本票出库').attributes('disabled')).toBeUndefined(); expect(sessionStorage.length).toBe(0)
|
||||
const original = vi.mocked(outboundWarehouseTransfer).mock.calls[0]![2]
|
||||
await button(wrapper, '确认实际出库').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' })
|
||||
can.mockReturnValue(false); const denied = render(); expect(denied.text()).not.toContain('确认本票出库')
|
||||
})
|
||||
it('queries only selected warehouse page, reads exact bound location and keeps receipt request after response loss', async () => {
|
||||
it('queries only selected warehouse page, reads exact bound location and unlocks the selection after response loss', async () => {
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
|
||||
const p = { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 }
|
||||
vi.mocked(listPallets).mockResolvedValue({ records: [p], page: 1, size: 20, total: 1, pages: 1 })
|
||||
@@ -109,10 +107,10 @@ describe('transfer movement UI', () => {
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(receiveWarehouseTransfer).mock.calls[0]))
|
||||
expect(original[2]).toMatchObject({ sourceInventoryPositionId: 'hidden-source', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY' })
|
||||
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
|
||||
expect(button(wrapper, '查询目标托盘').attributes('disabled')).toBeUndefined()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]![2].idempotencyKey).not.toBe(original[2].idempotencyKey)
|
||||
expect(wrapper.text()).not.toContain('hidden-source')
|
||||
})
|
||||
it('does not request resources when permission is absent or source already received', async () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
|
||||
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload } from '../api/warehouseTransfers'
|
||||
import { getPallet, listPallets, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
|
||||
@@ -15,13 +14,6 @@ const emit = defineEmits<{ updated: [detail: WarehouseTransferDetailDto] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
type Pending = { type: 'outbound'; body: OutboundWarehouseTransferPayload } | { type: 'receipt'; body: ReceiveWarehouseTransferPayload }
|
||||
const pending = ref<Pending | null>(null)
|
||||
const definiteRejection = ref(false), reselectAllowed = ref(false), rejectionEvidence = ref<ApiErrorDetails | null>(null)
|
||||
function definiteBusinessRejection(cause: unknown) {
|
||||
const codes = ['A0400', 'A0404', 'A0409', 'A0422']
|
||||
const statusKnown = (status?: number) => status === undefined || [200, 400, 404, 409, 422].includes(status)
|
||||
if (cause instanceof ApiResponseError) return codes.includes(cause.code) && statusKnown(cause.status)
|
||||
return axios.isAxiosError<{ code?: string }>(cause) && !!cause.response && statusKnown(cause.response.status) && codes.includes(cause.response.data?.code || '')
|
||||
}
|
||||
const completedTargets = computed(() => props.detail.receiptCommands?.filter(c => c.status === 'COMPLETED').map(c => c.target).filter((t, i, all) => all.findIndex(v => v.warehouseId === t.warehouseId && v.palletId === t.palletId && v.locationId === t.locationId) === i) ?? [])
|
||||
const previous = computed(() => completedTargets.value[0] ?? null)
|
||||
function acknowledgedTarget(value: ReceiptTarget) { return completedTargets.value.find(t => t.warehouseId === value.warehouse.id && t.palletId === value.pallet.id && t.locationId === value.location.id) ?? null }
|
||||
@@ -32,65 +24,23 @@ const pallets = ref<PalletDto[]>([]), target = ref<ReceiptTarget | null>(null),
|
||||
let generation = 0
|
||||
const canManage = computed(() => access.can('lastleg:warehouse-transfer:manage'))
|
||||
const canResources = computed(() => canManage.value && access.can('platform:overseas-warehouse:warehouse:list') && access.can('platform:overseas-warehouse:pallet:list') && access.can('platform:overseas-warehouse:location:list'))
|
||||
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED'))
|
||||
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED' && !unfinished.value.some(c => c.sourceInventoryPositionId === a.inventoryPositionId)))
|
||||
const outboundReady = computed(() => canManage.value && canOutboundTransfer(props.detail))
|
||||
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0 && !unfinished.value.length)
|
||||
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0)
|
||||
const selectedValid = computed(() => target.value && shipped.value.some(a => a.inventoryPositionId === sourceId.value) && receiptTargetValid(props.detail, target.value, acknowledgedTarget(target.value)))
|
||||
const storageKey = () => `warehouse-transfer-movement:${props.customerId}:${props.detail.transferNo}`
|
||||
function persist() { sessionStorage.setItem(storageKey(), JSON.stringify({ pending: pending.value, definiteRejection: definiteRejection.value })) }
|
||||
function restore() {
|
||||
generation++; busy.value = false; pending.value = null; error.value = null; notice.value = ''; open.value = false
|
||||
definiteRejection.value = false; reselectAllowed.value = false; rejectionEvidence.value = null
|
||||
function reset() {
|
||||
generation++; pending.value = null; busy.value = false; error.value = null; notice.value = ''; open.value = false
|
||||
warehouses.value = []; pallets.value = []; warehouseId.value = ''; target.value = null; sourceId.value = ''; keyword.value = ''; page.value = 1; total.value = 0
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey())
|
||||
if (!saved) { recoverServerCommand(); return }
|
||||
const value: { pending: Pending | null; definiteRejection?: boolean } = JSON.parse(saved)
|
||||
if (value.pending && (!['outbound', 'receipt'].includes(value.pending.type) || !value.pending.body?.idempotencyKey)) throw new Error('invalid saved command')
|
||||
pending.value = value.pending; definiteRejection.value = value.definiteRejection === true; recoverServerCommand()
|
||||
} catch { error.value = { message: '本次操作记录无法读取,请联系管理员核对原操作,暂不能提交新操作。' }; busy.value = true }
|
||||
}
|
||||
function recoverServerCommand(command?: WarehouseTransferReceiptCommandDto) {
|
||||
if (pending.value?.type === 'receipt' && props.detail.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === pending.value?.body.idempotencyKey)) { pending.value = null; persist() }
|
||||
const server = command ?? (unfinished.value.length === 1 ? unfinished.value[0] : undefined)
|
||||
if (server && canManage.value) {
|
||||
definiteRejection.value = false; reselectAllowed.value = false
|
||||
pending.value = { type: 'receipt', body: JSON.parse(JSON.stringify(server.request)) as ReceiveWarehouseTransferPayload }
|
||||
notice.value = '已从本票服务端记录恢复原实收操作。'
|
||||
}
|
||||
}
|
||||
watch(() => [props.customerId, props.detail.transferNo], restore, { immediate: true })
|
||||
watch(() => props.detail.receiptCommands, () => { if (!busy.value) recoverServerCommand() })
|
||||
watch(() => [props.customerId, props.detail.transferNo], reset, { immediate: true })
|
||||
async function checkCurrent() {
|
||||
if (busy.value) return
|
||||
const current = generation, customerId = props.customerId, no = props.detail.transferNo
|
||||
const current = generation
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const value = await getWarehouseTransfer(customerId, no)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no) throw new Error('返回的转运单不一致')
|
||||
const command = pending.value
|
||||
reselectAllowed.value = !!(command?.type === 'receipt' && definiteRejection.value && Array.isArray(value.receiptCommands)
|
||||
&& !value.receiptCommands.some(c => c.request.idempotencyKey === command.body.idempotencyKey)
|
||||
&& !value.receiptCommands.some(c => c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)
|
||||
&& value.allocations?.some(a => a.inventoryPositionId === command.body.sourceInventoryPositionId && a.status === 'SHIPPED'))
|
||||
if ((command?.type === 'outbound' && ['OUTBOUNDED', 'PARTIALLY_RECEIVED', 'RECEIVED'].includes(value.status))
|
||||
|| (command?.type === 'receipt' && value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey))) { pending.value = null; persist() }
|
||||
emit('updated', value)
|
||||
notice.value = reselectAllowed.value ? '服务端确认原请求未建立实收命令,来源仍待实收,可重新选择目标。' : pending.value ? '已核对当前状态;原操作仍保留,可使用原内容确认结果。' : '当前转运状态已刷新。'
|
||||
}
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态核对失败') }
|
||||
try { const value = await getWarehouseTransfer(props.customerId, props.detail.transferNo); if (current === generation) { emit('updated', value); notice.value = '当前转运状态已刷新。' } }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态读取失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function reselectTarget() {
|
||||
if (!reselectAllowed.value || busy.value || pending.value?.type !== 'receipt' || !definiteRejection.value) return
|
||||
const original = pending.value
|
||||
if (error.value) rejectionEvidence.value = error.value
|
||||
try { pending.value = null; definiteRejection.value = false; persist() }
|
||||
catch (cause) { pending.value = original; definiteRejection.value = true; error.value = getApiErrorDetails(cause, '原操作记录无法更新,暂不能重新选择'); return }
|
||||
reselectAllowed.value = false; target.value = null; sourceId.value = ''; keyword.value = ''; pallets.value = []; error.value = null
|
||||
await openReceipt()
|
||||
}
|
||||
async function openReceipt() {
|
||||
if (!receiptReady.value || busy.value || pending.value) return
|
||||
open.value = true; busy.value = true; error.value = null; const current = generation
|
||||
@@ -122,15 +72,38 @@ async function selectPallet(id: string) {
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
function changeWarehouse() { target.value = null; pallets.value = []; page.value = 1; total.value = 0 }
|
||||
function receiptSourceLabel(source: string) {
|
||||
const allocation = props.detail.allocations?.find(row => row.inventoryPositionId === source)
|
||||
return [allocation?.inboundCode, allocation?.palletNo].filter(Boolean).join(' / ') || '来源信息待核实'
|
||||
}
|
||||
/** 按服务端当前实收记录继续同一来源;不缓存失败请求,不影响其他来源作业。 */
|
||||
async function confirmExistingReceipt(source: string) {
|
||||
if (busy.value || !canResources.value) return
|
||||
const current = generation, customer = props.customerId, no = props.detail.transferNo
|
||||
busy.value = true; error.value = null; notice.value = ''
|
||||
try {
|
||||
const latest = await getWarehouseTransfer(customer, no)
|
||||
if (current !== generation) return
|
||||
if (latest.transferNo !== no) throw new Error('返回的转运单不一致')
|
||||
emit('updated', latest)
|
||||
const command = latest.receiptCommands?.find(row => row.sourceInventoryPositionId === source)
|
||||
if (!command) throw new Error('本来源实收记录已变化,请按当前状态重新选择')
|
||||
if (command.status === 'COMPLETED') { notice.value = '本来源目标实收已确认。'; return }
|
||||
if (!['PREPARED', 'RESERVED'].includes(command.status) || command.request.sourceInventoryPositionId !== source) throw new Error('本来源实收状态无效,请刷新查看')
|
||||
const value = await receiveWarehouseTransfer(customer, no, command.request)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no || !value.receiptCommands?.some(row => row.sourceInventoryPositionId === source && row.status === 'COMPLETED' && row.request.idempotencyKey === command.request.idempotencyKey)) throw new Error('本来源尚未返回实收完成记录,请刷新查看')
|
||||
emit('updated', value); notice.value = '本来源目标实收已确认。'
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本来源实收未完成,请重新操作') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function submit(type: 'outbound' | 'receipt') {
|
||||
if (busy.value || !canManage.value || (type === 'receipt' && !canResources.value)) return
|
||||
if (!pending.value) {
|
||||
definiteRejection.value = false; reselectAllowed.value = false
|
||||
try {
|
||||
if (type === 'outbound') { if (!outboundReady.value) return; pending.value = { type, body: { idempotencyKey: crypto.randomUUID() } } }
|
||||
else { if (!receiptReady.value || !target.value || !selectedValid.value) return; pending.value = { type, body: createReceiptCommand(props.detail, sourceId.value, target.value, crypto.randomUUID(), acknowledgedTarget(target.value)) } }
|
||||
persist() // Preserve the exact command before transport, including across close/navigation/reload.
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原操作记录保存失败,未提交请求'); pending.value = null; return }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '操作内容校验失败'); pending.value = null; return }
|
||||
}
|
||||
const command = pending.value
|
||||
if (command.type !== type) return
|
||||
@@ -140,19 +113,24 @@ async function submit(type: 'outbound' | 'receipt') {
|
||||
const value = command.type === 'outbound' ? await outboundWarehouseTransfer(customer, no, command.body) : await receiveWarehouseTransfer(customer, no, command.body)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no) throw new ApiResponseError('操作响应与当前转运单不一致,请核对原操作', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请保留原操作并只读核对', 'TRANSFER_RECEIPT_RECORD_MISSING')
|
||||
pending.value = null; persist(); emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
|
||||
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请刷新后重新操作', 'TRANSFER_RECEIPT_RECORD_MISSING')
|
||||
pending.value = null; emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
|
||||
notice.value = command.type === 'outbound' ? '本票库存已确认出库。' : '本来源目标实收已确认。'
|
||||
} catch (cause) { if (current === generation) { pending.value = command; definiteRejection.value = definiteBusinessRejection(cause); reselectAllowed.value = false; error.value = getApiErrorDetails(cause, '操作结果尚未确认,已保留原内容,请核对后重试原操作'); if (definiteRejection.value) rejectionEvidence.value = error.value; try { persist() } catch { /* Existing pre-transport record remains authoritative for retry. */ } } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '操作失败,请刷新后重新操作') }
|
||||
finally { if (current === generation) { pending.value = null; busy.value = false } }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="转运出库与目标实收" class="movement-panel">
|
||||
<template v-if="canManage">
|
||||
<el-button :disabled="busy" @click="checkCurrent">刷新当前状态</el-button>
|
||||
<el-button v-if="outboundReady && !pending" :disabled="busy" @click="open = !open">确认本票出库</el-button>
|
||||
<el-button v-if="receiptReady && !pending" :disabled="busy" @click="openReceipt">登记目标仓实收</el-button>
|
||||
<div v-for="command in unfinished" :key="command.sourceInventoryPositionId" aria-label="待完成的来源实收">
|
||||
<p>{{ receiptSourceLabel(command.sourceInventoryPositionId) }} → {{ command.target.warehouseName }} / {{ command.target.palletNo }} / {{ command.target.locationCode }}</p>
|
||||
<el-button v-if="canResources" :disabled="busy" :loading="busy" @click="confirmExistingReceipt(command.sourceInventoryPositionId)">确认本来源实收</el-button>
|
||||
</div>
|
||||
<div v-if="open && outboundReady && !pending" aria-label="出库确认"><p>{{ detail.transferNo }} · {{ detail.packageCount }} 箱。确认实际移出后,全部已分配来源将发运。</p><el-button :loading="busy" @click="submit('outbound')">确认实际出库</el-button></div>
|
||||
<div v-if="open && receiptReady && !pending" aria-label="目标仓实收选择">
|
||||
<el-form label-position="top" class="movement-fields">
|
||||
@@ -166,15 +144,10 @@ async function submit(type: 'outbound' | 'receipt') {
|
||||
<p v-if="target">目标:{{ target.warehouse.warehouseCode }} / {{ target.pallet.palletCode }} / {{ target.location.locationCode }} · FBA {{ target.pallet.fbaWarehouseCode }}</p>
|
||||
<el-button :disabled="busy || !selectedValid" :loading="busy" @click="submit('receipt')">确认本来源实收</el-button>
|
||||
</div>
|
||||
<p v-if="pending">原操作内容已保留,请先核对当前状态,再使用原内容确认结果。{{ pending.type === 'receipt' ? `${pending.body.destinationPalletNo} / ${pending.body.warehouseLocationCode}` : detail.transferNo }}</p>
|
||||
<template v-if="unfinished.length > 1 && !pending"><p>本票存在多条未完成实收,请选择需要恢复的原操作。</p><el-button v-for="c in unfinished" :key="c.request.idempotencyKey" :disabled="busy" @click="recoverServerCommand(c)">恢复 {{ c.target.palletNo }} / {{ c.target.locationCode }}</el-button></template>
|
||||
<el-button v-if="pending" :disabled="busy || (pending.type === 'receipt' && !canResources)" :loading="busy" @click="submit(pending.type)">重试原操作</el-button>
|
||||
<el-button v-if="pending" :disabled="busy" @click="checkCurrent">只读核对当前状态</el-button>
|
||||
<el-button v-if="reselectAllowed && pending?.type === 'receipt'" :disabled="busy" @click="reselectTarget">重新选择目标</el-button>
|
||||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !canResources">登记目标实收需要收货仓、托盘、库位查询及转运管理权限。</p>
|
||||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !Array.isArray(detail.receiptCommands)">本票尚未返回服务端实收操作记录,请联系管理员核对接口后再登记实收。</p>
|
||||
</template>
|
||||
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><div v-if="rejectionEvidence"><p>上次目标选择被拒绝:{{ rejectionEvidence.message }}</p><ApiErrorMeta :details="rejectionEvidence" /></div>
|
||||
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -16,12 +16,12 @@ it.each([new Error('network unknown'), new ApiResponseError('server failure','S0
|
||||
expect(send.mock.calls[0]![0].body).toEqual({recordVersion:2,idempotencyKey:'second',remark:'edited'})
|
||||
expect(reopened.pending.value).toBeNull()
|
||||
})
|
||||
it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('clears saved failed requests without restoring them',saved=>{
|
||||
it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('ignores saved failed requests without accessing storage',saved=>{
|
||||
const owner=crypto.randomUUID()
|
||||
sessionStorage.setItem(`dispatch-command:${owner}`,saved)
|
||||
const state=useDispatchCommandRecovery(()=>owner)
|
||||
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
|
||||
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
|
||||
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBe(saved)
|
||||
})
|
||||
it('prevents double clicks only during the active request',async()=>{
|
||||
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { CommandResult, DispatchAction } from './dispatchContracts'
|
||||
import { requireVersion } from './dispatchContracts'
|
||||
|
||||
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
|
||||
const storageKey = (scope: string) => `dispatch-command:${scope}`
|
||||
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
|
||||
export function isDefiniteDispatchRejection(error: unknown) {
|
||||
@@ -28,7 +27,6 @@ export function validateCurrentDispatch(value: unknown, result: CommandResult) {
|
||||
/** 仅在当前请求期间防止连点;失败立即释放页面,不持久化或恢复历史请求。 */
|
||||
export function useDispatchCommandRecovery(scope: () => string) {
|
||||
const owner = scope()
|
||||
if (owner) sessionStorage.removeItem(storageKey(owner))
|
||||
const existing = sessions.get(owner)
|
||||
const pending = existing?.pending || ref<PendingDispatchCommand | null>(null)
|
||||
const busy = existing?.busy || ref(false)
|
||||
|
||||
@@ -26,11 +26,11 @@ export interface DispatchBatchAllocation { planPalletId: string; batchKey: strin
|
||||
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
|
||||
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
|
||||
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record<DispatchStage, number>; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
|
||||
export interface DispatchListRow { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
|
||||
export interface DispatchListRow { createdAt: string | null; fbaAppointments: FbaAppointment[]; dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
|
||||
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
|
||||
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
||||
export interface FbaAppointment { batchKey: string | null; batchNo: string | null; customerId: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null; shipmentNumbers: string[] }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
|
||||
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
|
||||
@@ -40,7 +40,7 @@ export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'P
|
||||
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[]; publishLooseboardDemand: boolean }
|
||||
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
||||
export interface UpdateDispatch extends VersionCommand { maintenance?: DispatchVehicleMaintenance | null; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; publishLooseboardDemand: boolean }
|
||||
export interface UpdateDispatch extends VersionCommand { maintenance?: DispatchVehicleMaintenance | null; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
|
||||
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface ReasonCommand extends VersionCommand { reason: string }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments }
|
||||
@@ -58,6 +58,11 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export interface AppointmentAllocationOption { batchKey: string; batchNo: string; recordVersion: Version; availablePackages: number; confirmationNo: string | null; appointmentAt: string; appointmentEndAt: string | null; timezone: string }
|
||||
export interface AppointmentAllocationSource { sourceKey: string; orderNo: string | null; inboundCode: string | null; destinationKey: string; packageCount: number | null; options: AppointmentAllocationOption[]; blockReasons: string[] }
|
||||
export interface AppointmentAllocationItem { dispatchItemId: string; planPalletId: string; customerId: string; customerName: string | null; palletNo: string; sources: AppointmentAllocationSource[]; allocations: DispatchBatchAllocation[] }
|
||||
export interface AppointmentAllocations { dispatchNo: string; recordVersion: Version; items: AppointmentAllocationItem[]; edit: Gate }
|
||||
export interface AppointmentAllocationCommand extends VersionCommand { dispatchItemIds: string[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export type DispatchEvidenceKind = 'EXCEPTION' | 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
|
||||
|
||||
@@ -9,7 +9,7 @@ export function sharedPalletCandidates(): Candidate[] {
|
||||
return rows.map(row => ({ ...row, selectionGroup: group }))
|
||||
}
|
||||
export function detail(overrides: Partial<DispatchDetail> = {}): 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> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
||||
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { createdAt: '2026-10-19T08:00:00Z', fbaAppointments: [], 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> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
|
||||
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export interface DispatchDestinationAppointment { destinationKey: string; appointmentNo: string | null }
|
||||
|
||||
export interface DispatchVehicleMaintenance {
|
||||
plannedDeliveryDate: string | null
|
||||
deliveryMethod: string | null
|
||||
@@ -6,16 +8,16 @@ export interface DispatchVehicleMaintenance {
|
||||
fleetEmail: string | null
|
||||
vehicleType: string | null
|
||||
appointmentAccount: string | null
|
||||
appointmentNo: string | null
|
||||
destinationAppointments: DispatchDestinationAppointment[]
|
||||
}
|
||||
|
||||
/** 本车补充资料保持未填为空,不从原型示例或实际发车时间推定。 */
|
||||
export function blankVehicleMaintenance(): DispatchVehicleMaintenance {
|
||||
return { plannedDeliveryDate: null, deliveryMethod: null, pickupMethod: null, loadType: null, fleetEmail: null, vehicleType: null, appointmentAccount: null, appointmentNo: null }
|
||||
return { plannedDeliveryDate: null, deliveryMethod: null, pickupMethod: null, loadType: null, fleetEmail: null, vehicleType: null, appointmentAccount: null, destinationAppointments: [] }
|
||||
}
|
||||
|
||||
/** 保存前只整理已填写资料,不据补充资料触发派送、预约或费用。 */
|
||||
export function vehicleMaintenancePayload(value: DispatchVehicleMaintenance): DispatchVehicleMaintenance {
|
||||
const text = (item: string | null) => item?.trim() || null
|
||||
return { plannedDeliveryDate: text(value.plannedDeliveryDate), deliveryMethod: text(value.deliveryMethod), pickupMethod: text(value.pickupMethod), loadType: text(value.loadType), fleetEmail: text(value.fleetEmail), vehicleType: text(value.vehicleType), appointmentAccount: text(value.appointmentAccount), appointmentNo: text(value.appointmentNo) }
|
||||
return { plannedDeliveryDate: text(value.plannedDeliveryDate), deliveryMethod: text(value.deliveryMethod), pickupMethod: text(value.pickupMethod), loadType: text(value.loadType), fleetEmail: text(value.fleetEmail), vehicleType: text(value.vehicleType), appointmentAccount: text(value.appointmentAccount), destinationAppointments: value.destinationAppointments.map(row => ({ destinationKey: row.destinationKey, appointmentNo: text(row.appointmentNo) })) }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export type { FbaAppointmentDto, BatchFbaAppointmentPayload, UpdateFbaAppointmen
|
||||
export { default as FbaAppointmentFields } from './components/FbaAppointmentFields.vue'
|
||||
export { createAppointmentForm, appointmentPayload, appointmentFormError } from './appointmentForm'
|
||||
export { overseasWarehousePermissions } from './permissions'
|
||||
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
||||
export { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
||||
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
|
||||
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
|
||||
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(pane.isVisible()).toBe(true)
|
||||
expect(pane.text()).toContain('LAX → ONT8')
|
||||
expect(pane.text()).toContain('派送异常记录读取失败')
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
|
||||
expect(pane.find('[aria-label="派送异常记录"]').exists()).toBe(false)
|
||||
await pane.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
@@ -101,7 +101,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
|
||||
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
|
||||
}
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—'])
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -121,7 +121,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledWith('PC/1')
|
||||
@@ -133,8 +133,8 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
|
||||
expect(dispatchInfo.isVisible()).toBe(true)
|
||||
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.find('[aria-label="正式FBA预约"]').exists()).toBe(true); expect(dispatchInfo.text()).toContain('FBA-AP-1')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
||||
})
|
||||
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { stageLabel } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
import DispatchAppointments from '../components/DispatchAppointments.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -35,7 +36,6 @@ const vehicleFields = computed(() => [
|
||||
const dispatchFields = computed(() => [
|
||||
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
||||
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
||||
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
||||
])
|
||||
const documentFields = computed(() => [
|
||||
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
||||
@@ -78,7 +78,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchAppointments :appointments="detail?.dispatchInfo.fbaAppointments || []" />
|
||||
<LoadErrorState v-if="detail && !Array.isArray(detail.dispatchInfo.exceptions)" compact title="派送异常记录读取失败" message="排车详情返回的异常记录不完整,请重新加载。" :loading="loading" @retry="load" />
|
||||
<section v-else-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||||
<h3>派送异常记录</h3>
|
||||
|
||||
@@ -9,7 +9,8 @@ import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { cancelTransport, getTransport } from '../api/transportTasks'
|
||||
import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
|
||||
import type { DispatchPage } from '../dispatchContracts'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { BeforeLoadingSection, DispatchPage, DispatchStage } from '../dispatchContracts'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
@@ -442,3 +443,34 @@ it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_
|
||||
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('splits driver and phone without fallback and shows authoritative appointment and shipment fields', async () => {
|
||||
const row = listRow(); row.resources.driver!.name = null; row.resources.driver!.phone = '555-REAL'
|
||||
row.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', batchNo: 'BATCH-1', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }]
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([row]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const columns = wrapper.getComponent(DataTable).props('columns')
|
||||
expect(columns.map(column => column.label)).toContain('司机'); expect(columns.map(column => column.label)).toContain('联系电话')
|
||||
expect(columns[0]!.key).toBe('actions')
|
||||
expect(wrapper.text()).toContain('555-REAL'); expect(wrapper.text()).toContain('CONF-1'); expect(wrapper.text()).toContain('SHIP-1、SHIP-2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['BEFORE_LOADING', 'VEHICLE_INFO'], ['BEFORE_LOADING', 'DISPATCH_REVIEW'], ['BEFORE_LOADING', 'LOADING_REVIEW'],
|
||||
['LOADING', 'VEHICLE_INFO'], ['AFTER_LOADING', 'VEHICLE_INFO'], ['DELIVERING', 'VEHICLE_INFO'], ['DELIVERED', 'VEHICLE_INFO'],
|
||||
] as const)('keeps actual creation time visible in %s / %s for every transport type', async (stage, section) => {
|
||||
const createdAt = '2026-10-22T01:23:45Z'
|
||||
const rows = (['DELIVERY', 'PICKUP', 'LOCAL_TRUCK'] as const).map(taskType => listRow({ dispatchNo: 'PC-' + taskType, taskType, stage, createdAt, plannedDepartureAt: '2026-11-01T11:00:00Z' }))
|
||||
vi.mocked(listDispatches).mockResolvedValue(page(rows))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { stage: DispatchStage; section: BeforeLoadingSection }
|
||||
state.stage = stage; state.section = section; await flushPromises()
|
||||
const table = wrapper.getComponent(DataTable), columns = table.props('columns')
|
||||
expect(columns[0]!.key).toBe('actions')
|
||||
expect(columns.find(column => column.key === 'createdAt')).toMatchObject({ label: '创建时间', slotName: 'createdAt' })
|
||||
const cells = wrapper.findAll('tbody td').filter(cell => cell.text() === formatOverseasDateTime(createdAt))
|
||||
expect(cells).toHaveLength(3)
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -70,7 +70,12 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机', minWidth: 100, slotName: 'driver' },
|
||||
{ key: 'driverPhone', label: '联系电话', minWidth: 140, slotName: 'driverPhone' },
|
||||
{ key: 'appointmentNo', label: '预约号', minWidth: 160, slotName: 'appointmentNo' },
|
||||
{ key: 'appointmentAt', label: '确认送达时间', minWidth: 170, slotName: 'appointmentAt' },
|
||||
{ key: 'shipmentNumbers', label: '货件号', minWidth: 180, slotName: 'shipmentNumbers' },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
|
||||
@@ -215,10 +220,15 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}</template>
|
||||
<template #driverPhone="{ row }">{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #appointmentNo="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.appointmentNo || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #appointmentAt="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ time(item.confirmedAt) }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #shipmentNumbers="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.shipmentNumbers?.join('、') || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #vehicleStatus="{ row }"><el-tag v-if="row.status === 'ACTIVE' && vehicleStatusForStage(row.stage)" :type="vehicleStatusForStage(row.stage)?.type">{{ vehicleStatusForStage(row.stage)?.label }}</el-tag><span v-else>—</span></template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
|
||||
<template #createdAt="{ row }">{{ time(row.createdAt) }}</template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
|
||||
@@ -266,7 +266,7 @@ onMounted(() => { void load() })
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
|
||||
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
</div></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listContainerArrivalTimes } from '@/modules/container-loading'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
createManualInboundPlan,
|
||||
@@ -35,6 +36,10 @@ vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/container-loading', async () => ({
|
||||
...await import('../../container-loading/warehouseArrival'),
|
||||
listContainerArrivalTimes: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
getReturnOrderStatistics: vi.fn(),
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
@@ -54,6 +59,10 @@ function memoryStorage(): Storage {
|
||||
}
|
||||
|
||||
const bookingId = '2092873463568597001'
|
||||
const arrivalTime = {
|
||||
sourceContainerBookingId: bookingId, estimatedWarehouseArrivalAt: '2026-09-01T08:00:00',
|
||||
actualWarehouseArrivalAt: null, version: 1, planCount: 0, plannedDate: '2026-09-01', isDefault: false,
|
||||
}
|
||||
const emptyReturnStatistics = { totals: { orderCount: 0, skuTypes: 0, expectedQuantity: 0, receivedQuantity: 0, remainingQuantity: 0, receivedBoxCount: 0, receivedParcelCount: 0, receivedPalletCount: 0 }, pageDimensions: [] }
|
||||
beforeEach(() => {
|
||||
vi.mocked(getReturnOrderStatistics).mockReset().mockResolvedValue(emptyReturnStatistics)
|
||||
@@ -171,6 +180,12 @@ async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows:
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
/** 定向验证入库弹框,独立于列表页签和批量操作入口。 */
|
||||
async function openArrivalPlan(wrapper: Awaited<ReturnType<typeof mountView>>, source = selectedRow()) {
|
||||
;(wrapper.vm as unknown as { openInboundPlan(source: object): void }).openInboundPlan(source)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
|
||||
return wrapper.findAll('button').find((item) => item.text().includes(text))
|
||||
}
|
||||
@@ -386,6 +401,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
||||
})
|
||||
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
|
||||
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
|
||||
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
|
||||
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
|
||||
@@ -654,11 +670,35 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
|
||||
it.each([
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-25T09:30:00', plannedDate: '2026-10-25' }, '2026-10-25 09:30'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01(默认推算)'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'],
|
||||
])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival])
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
|
||||
await button(wrapper, '入库')!.trigger('click')
|
||||
await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' }))
|
||||
|
||||
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
|
||||
const source = wrapper.get('[aria-label="入库来源"]')
|
||||
expect(source.text()).toContain(expectedText)
|
||||
if (!arrival.estimatedWarehouseArrivalAt) expect(source.text()).not.toContain('00:00')
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createManualInboundPlan).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(createManualInboundPlan).mock.calls[0][0]).not.toHaveProperty('plannedArrivalDate')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ arrivals: [] },
|
||||
{ arrivals: [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, plannedDate: null }] },
|
||||
{ arrivals: [{ ...arrivalTime, version: -1 }] },
|
||||
])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals)
|
||||
const wrapper = await openPending()
|
||||
await openArrivalPlan(wrapper)
|
||||
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
||||
@@ -666,6 +706,37 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows arrival read failures and does not reuse the list timestamp', async () => {
|
||||
vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503))
|
||||
const wrapper = await openPending()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.text()).toContain('到仓来源暂不可读')
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).not.toContain('2026-09-01')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => {
|
||||
let resolveOld!: (value: typeof arrivalTime[]) => void
|
||||
vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = await openPending()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValueOnce([{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-26T10:00:00', plannedDate: '2026-10-26' }])
|
||||
await openArrivalPlan(wrapper)
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00')
|
||||
resolveOld([arrivalTime]); await flushPromises()
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00')
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
|
||||
...preview,
|
||||
@@ -1238,11 +1309,17 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
|
||||
})
|
||||
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
|
||||
const wrapper = await mountView()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('width')).toContain('720px')
|
||||
expect(dialog.props('width')).toBe('min(1160px, calc(100vw - 48px))')
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('top')).toBe('6vh')
|
||||
expect(dialog.get('[aria-label="入库来源"] h3').text()).toContain('来源摘要')
|
||||
expect(dialog.get('[aria-label="入库计划登记"] h3').text()).toBe('计划设置')
|
||||
const sourceRows = dialog.findAll('.inbound-plan-source tbody tr')
|
||||
expect(sourceRows[0].get('td').attributes('colspan')).toBe('3')
|
||||
expect(sourceRows[1].findAll('th').map(cell => cell.text())).toEqual(['计划入库仓', '入仓码'])
|
||||
expect(dialog.props('confirmText')).toBe('保存并生成入库单')
|
||||
expect(dialog.props('title')).toContain('LMO-260827-0001')
|
||||
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
|
||||
await dialog.get('textarea').setValue('全屏后保留')
|
||||
@@ -1253,9 +1330,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
|
||||
const wrapper = await mountView()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
await dialog.get('textarea').setValue('保留入库备注')
|
||||
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizonta
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listContainerArrivalTimes, projectWarehouseArrival, type ContainerArrivalTime } from '@/modules/container-loading'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -110,12 +111,23 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
|
||||
const inboundPlanPreviewLoading = ref(false)
|
||||
const inboundPlanPreviewError = ref('')
|
||||
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
|
||||
const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt
|
||||
const inboundPlanArrival = ref<ContainerArrivalTime | null>(null)
|
||||
const inboundPlanArrivalLoading = ref(false)
|
||||
const inboundPlanArrivalError = ref('')
|
||||
let inboundPlanArrivalRequest = 0
|
||||
const inboundPlanArrivalProjection = computed(() => {
|
||||
const arrival = inboundPlanArrival.value
|
||||
return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
})
|
||||
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date
|
||||
|| (inboundPlanIsDropship.value
|
||||
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
||||
|| inboundPlanSourcePalletCount.value <= 0)))
|
||||
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—')
|
||||
const inboundPlanExpectedArrival = computed(() => {
|
||||
const { date, time, isDefault } = inboundPlanArrivalProjection.value
|
||||
return date ? `${date}${time ? ` ${time}` : ''}${isDefault ? '(默认推算)' : ''}` : '—'
|
||||
})
|
||||
const holdOpen = ref(false)
|
||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||
const manualNodesOpen = ref(false)
|
||||
@@ -633,9 +645,37 @@ function openInboundPlan(source?: object) {
|
||||
inboundPlanPreviewError.value = ''
|
||||
Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
actionError.value = null
|
||||
inboundPlanArrival.value = null
|
||||
inboundPlanArrivalError.value = ''
|
||||
inboundPlanOpen.value = true
|
||||
void refreshInboundPlanArrival(target)
|
||||
void refreshManualPalletMinimum(target)
|
||||
}
|
||||
/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */
|
||||
async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
|
||||
const request = ++inboundPlanArrivalRequest
|
||||
inboundPlanArrivalLoading.value = true
|
||||
try {
|
||||
if (!target.sourceContainerBookingId) {
|
||||
inboundPlanArrivalError.value = '尾程订单未绑定来源柜,无法取得预计到仓时间'
|
||||
return
|
||||
}
|
||||
const arrivals = await listContainerArrivalTimes([target.sourceContainerBookingId])
|
||||
if (request !== inboundPlanArrivalRequest || !inboundPlanOpen.value) return
|
||||
const arrival = arrivals.find(item => item.sourceContainerBookingId === target.sourceContainerBookingId)
|
||||
if (!arrival || arrival.version < 0) {
|
||||
inboundPlanArrivalError.value = '来源柜预计到仓日期缺失或不一致,请先核对柜级时间'
|
||||
return
|
||||
}
|
||||
inboundPlanArrival.value = arrival
|
||||
} catch (error) {
|
||||
if (request === inboundPlanArrivalRequest && inboundPlanOpen.value) {
|
||||
inboundPlanArrivalError.value = getApiErrorDetails(error, '预计到仓时间加载失败,请重新打开入库计划重试').message
|
||||
}
|
||||
} finally {
|
||||
if (request === inboundPlanArrivalRequest) inboundPlanArrivalLoading.value = false
|
||||
}
|
||||
}
|
||||
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
|
||||
if (!target.sourceContainerBookingId) return
|
||||
inboundPlanPreviewLoading.value = true
|
||||
@@ -671,7 +711,7 @@ async function submitInboundPlan() {
|
||||
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
||||
return
|
||||
}
|
||||
if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||
return
|
||||
}
|
||||
@@ -1288,25 +1328,28 @@ onMounted(() => { void load() })
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<AppDialog class="inbound-plan-dialog admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(1160px, calc(100vw - 48px))" top="6vh" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<div class="inbound-plan-content">
|
||||
<el-alert title="保存后生成入库单;首个入库计划生成后,整柜预计到仓时间不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts"><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
|
||||
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
|
||||
<table class="density-table density-table--editable last-mile-command-table"><tbody>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
|
||||
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
|
||||
@@ -1332,6 +1375,14 @@ onMounted(() => { void load() })
|
||||
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
|
||||
.last-mile-dialog-facts th { width: var(--admin-label-width); }
|
||||
.last-mile-command-table td:not([colspan]) { width: 50%; }
|
||||
.inbound-plan-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.inbound-plan-form { margin-top: 0; }
|
||||
.inbound-plan-source__label { width: 112px; }
|
||||
.inbound-plan-source td { overflow-wrap: anywhere; }
|
||||
.inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.inbound-plan-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.inbound-plan-form :deep(.el-select) { width: 100%; }
|
||||
.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); }
|
||||
.manual-nodes-editor th { width: 180px; }
|
||||
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
|
||||
.last-mile-toolbar { min-width: 0; }
|
||||
|
||||
@@ -306,7 +306,7 @@ onMounted(load)
|
||||
<LoadErrorState v-if="createError" title="出库操作未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<el-alert v-if="!access.can(permission.outboundCreate)" title="当前账号没有新建出库权限。" type="warning" :closable="false" show-icon />
|
||||
<el-form class="create-outbound-form density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="createLocked">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="出库类型" required>
|
||||
<section class="density-dialog-section" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="出库类型" required>
|
||||
<el-radio-group v-model="outboundType" @change="changeOutboundType">
|
||||
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
|
||||
<el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button>
|
||||
@@ -314,7 +314,7 @@ onMounted(load)
|
||||
</el-radio-group>
|
||||
</el-form-item></td></tr></tbody></table></section>
|
||||
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
|
||||
<section class="density-dialog-section" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
|
||||
<el-select :disabled="createLocked" v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
|
||||
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||
</el-select>
|
||||
@@ -342,7 +342,6 @@ onMounted(load)
|
||||
<el-table-column prop="volumeWeightKg" label="可出材积重(kg)" width="135" align="right" />
|
||||
<el-table-column prop="volumeCbm" label="可出体积(m³)" width="125" align="right" />
|
||||
</el-table>
|
||||
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="density-dialog-note">暂无可直接出库的已完成入库订单</div>
|
||||
<div class="candidate-pagination">
|
||||
<span aria-live="polite" title="翻页保留选择">已选择 {{ selectedDropshipIds.length }} 条 · 第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
|
||||
<el-pagination :disabled="createLocked" v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
|
||||
@@ -361,17 +360,17 @@ onMounted(load)
|
||||
<el-table-column prop="volumeCbm" label="总体积(m³)" width="120" align="right" />
|
||||
<el-table-column label="换标" width="110"><template #default="{ row }">{{ row.needRelabel ? `已换标${row.relabelNo ? ` · ${row.relabelNo}` : ''}` : '原箱出库' }}</template></el-table-column>
|
||||
<el-table-column label="主单目的去向" min-width="240" show-overflow-tooltip><template #default="{ row }"><StatusBadge :status="transferHasDestination(row as WarehouseTransferOutboundCandidate) ? 'READY' : 'PENDING'" :label="transferDestinationLabel(row as WarehouseTransferOutboundCandidate)" /></template></el-table-column>
|
||||
</el-table><p v-if="!inventoryLoading && !transferCandidates.length" class="density-dialog-note">暂无已入库且尚无目的地的仓储转运单</p></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="转运目的地登记"><h3 class="density-dialog-section__title">目的地</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
|
||||
</el-table></section>
|
||||
<section class="density-dialog-section" aria-label="转运目的地登记"><h3 class="density-dialog-section__title">目的地</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料<InlineHelp title="退货出库说明"><p>整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p></InlineHelp></h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
|
||||
<section class="density-dialog-section" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料<InlineHelp title="退货出库说明"><p>整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p></InlineHelp></h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
|
||||
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
|
||||
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||
</el-select>
|
||||
</el-form-item></td><td><el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="createLocked || !selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item></td><td><el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" data-tone="work" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
|
||||
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
|
||||
<div class="return-lists">
|
||||
<section class="return-list-card density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">SKU库存</h3>
|
||||
@@ -387,7 +386,7 @@ onMounted(load)
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('PalletsView', () => {
|
||||
})
|
||||
|
||||
it('loads the all-warehouse pallet list and summary when the page opens', async () => {
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 250, idleCount: 100, occupiedCount: 98, scrappedCount: 52 })
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 1250, idleCount: 1100, occupiedCount: 98, scrappedCount: 52 })
|
||||
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -42,10 +42,10 @@ describe('PalletsView', () => {
|
||||
expect(getPalletSummary).toHaveBeenCalledWith('', { page: 1, size: 10, keyword: undefined, palletType: undefined, status: undefined })
|
||||
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).toContain('PLT-1')
|
||||
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).not.toContain('请先选择仓库后查询')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('托盘总数250')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('闲置托盘100')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('占用托盘98')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
|
||||
const cards = wrapper.get('[aria-label="海外托盘数量概览"]').findAll('article')
|
||||
expect(cards.map(card => card.get('h2').text())).toEqual(['托盘总数', '闲置托盘', '占用托盘', '已报废'])
|
||||
expect(cards.map(card => card.get('strong').text())).toEqual(['1,250', '1,100', '98', '52'])
|
||||
expect(cards.map(card => card.get('.workbench-metric-value > span').text())).toEqual(['个', '个', '个', '个'])
|
||||
})
|
||||
|
||||
it('prints one pallet from the all-warehouse list and allows retry after failure', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Layers, Package, PackageCheck, PackageX, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -167,7 +167,24 @@ onMounted(initialize)
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
|
||||
<section class="summary-grid workbench-panel is-flush" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
|
||||
<section class="summary-grid" aria-label="海外托盘数量概览">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.totalCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">闲置托盘</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.idleCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Package aria-hidden="true" /></span><h2 class="workbench-metric-label">占用托盘</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.occupiedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="rose">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageX aria-hidden="true" /></span><h2 class="workbench-metric-label">已报废</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.scrappedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<ListPageLayout aria-label="托盘管理列表">
|
||||
|
||||
|
||||
@@ -203,13 +220,9 @@ onMounted(initialize)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-grid span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.pallet-editor-table { table-layout: fixed; }
|
||||
.pallet-editor-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; }
|
||||
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:var(--workbench-surface); flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--workbench-border); }
|
||||
.summary-grid article:last-child { border-right: 0; }
|
||||
.summary-grid strong { font-size: var(--admin-font-body); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--workbench-grid-gap); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -262,7 +262,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'vehicle: null, driver: null']) expect(dialog).toContain(symbol)
|
||||
expect(dialog).not.toContain('调整已有排车货物')
|
||||
expect(dialog).not.toContain('SmartDispatchExistingCargo')
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
|
||||
Reference in New Issue
Block a user