Compare commits

..
Author SHA1 Message Date
ouyangxq fa5692f731 Fix dispatch review action refresh and definite rejection recovery 2026-10-20 11:28:42 +08:00
45 changed files with 886 additions and 1418 deletions
@@ -3,6 +3,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
import { command } from '@/modules/overseas-warehouse/dispatchTestFixtures'
const access = vi.hoisted(() => ({ allowed: true }))
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
@@ -35,3 +36,41 @@ it('retries lookup as a read and keeps the original detail navigation', async ()
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount()
})
it.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => {
const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click')
if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable'))
else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' })
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.text()).toContain('来源已变化,请重新核对')
expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
wrapper.unmount()
})
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
recovery.pending.value!.attempted = true
recovery.pending.value!.result = command('ADD_CARGO', 'known-key', 'DS-60')
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
await button(wrapper, '刷新加货资料').trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
wrapper.unmount()
})
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
const wrapper = render(); await flushPromises()
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises()
expect(wrapper.text()).toContain('来源版本需重新核对')
dialog.vm.$emit('saved'); await flushPromises()
expect(wrapper.text()).not.toContain('来源版本需重新核对')
expect(wrapper.emitted('updated')).toHaveLength(2)
wrapper.unmount()
})
@@ -11,6 +11,7 @@ const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const detail = ref<DispatchDetail | null>(null), open = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true)
const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value))
@@ -29,11 +30,12 @@ async function load() {
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
finally { if (current === generation) loading.value = false }
}
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true }
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
function recover() {
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
}
function saved() { open.value = false; emit('updated'); void load() }
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
watch(() => props.dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++; emit('busy', false) })
</script>
@@ -42,10 +44,11 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
<h4>为原排车添加同仓货物</h4><p>本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。</p>
<p>箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。</p>
<LoadErrorState v-if="error" title="本车加货资格读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
<p v-if="recovery.pending.value">原排车请求尚未核对,不能另发加货命令。<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">核对原加货请求</el-button></p>
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></p>
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" />
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
</section>
</template>
@@ -34,7 +34,6 @@ type FilterModel = QueryModel & {
}
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
const emit = defineEmits<{ changed: [] }>()
const router = useRouter()
const access = useAccessPreviewStore()
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
@@ -219,7 +218,6 @@ function openDetail(row: InboundOrderSummary) {
}
async function handleTaskChanged() {
emit('changed')
const target = selectedInbound.value
if (!isReview.value) {
reviewDialogOpen.value = false
@@ -299,7 +297,6 @@ async function batchApproveMeasurements() {
}
}
await loadTasks()
if (successCount) emit('changed')
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
const pricingReason = [...new Set(pricingWarnings.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount && !pricingWarnings.length) ElMessage.success(`已批量审核通过 ${successCount} 条测量数据,费用初算已生成`)
@@ -6,16 +6,14 @@
- 海外采购:客户候选、采购仓候选、列表、详情操作流水及 `PATCH` 三态编辑;缺失字段不修改,显式 `null` 清空。
- 采购审核 `review`:采购详情页只对待审核订单和独立审核权限开放,提交订单版本、通过/拒绝决定、稳定幂等键和可选备注。
- 本土卡车:平台列表、独立详情页、创建和仅待审核状态允许的普通编辑;详情页使用 `truck:status` 权限接入订单审核、累计实收、超托审核、托盘分配、待派车、已派车、发运、完成及取消。全部命令提交当前版本和稳定幂等键,结果不明时保留原请求供重试;失败保留表单与错误关联信息。
- 本土卡车拼车来源订单保持承运安排只读,状态随既有来货、排车和签收事实推进;不展示独立单审核或状态命令。
- 本土卡车:平台列表、详情、创建和仅待审核状态允许的普通编辑。
- 本土散板:独立稳定子域的列表、详情数据承载、创建和仅待接单状态允许的编辑;详情只用于既有编辑页预填,不新增详情路由。
- canonical 权限统一为 `platform:overseas-business:*`;长整型 ID 与 decimal 字段在适配层归一化。
明确不接入:
- 采购出库不提供独立 `/outbounds` 端点;采购编辑通过出库明细维护出库量并派生待出库、部分出库、全部出库状态,不直接操作海外仓 PDA 可用库存。
- 散板独立单状态推进:后端存在,但当前原型没有对应操作承载方式。
- 独立卡车履约记录使用现有卡车接口,不自动生成 WMS 库存、海外仓排车单、出库单或派送单;派车、发运和完成按实际发生情况由平台确认。
- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。
- 海外出库、海外派送和 Merchant API。
剩余验收项仅为真实 Gateway/Token 环境 HTTP 验收及 `1920×1080`、浏览器缩放 `100%` 的人工页面操作和截图确认。
@@ -29,7 +29,7 @@
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_REVIEW` 可真实提交 |
| 本土卡车 | 详情、审核与后续履约 | 原型未定义;2026-10-20 用户明确要求补齐流程并加审核 | 独立详情页 + 居中操作确认弹框 | 列表待审核单显示审核入口,其他状态可查看详情;独立单使用既有具名命令,按状态与 truck:status 权限展示审核、实收、超托审核、分配、待派车、派车、发运、完成与取消。展示资料、托盘和审核操作历史;拼车来源沿承运安排只读。待人工 1920×1080、100% 截图确认 |
| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;使用独立稳定子域真实提交并显式选择客户 |
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_ACCEPTANCE` 可真实提交 |
@@ -7,11 +7,10 @@ import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
updateUsTruckOrder, executeUsTruckCommand,
updateUsTruckOrder,
} from './platformOverseasBusiness'
import type { UpdateLocalTransportOrderRequest } from '../types'
import { readPendingProcurement } from '../procurementPending'
import type { TruckOperation } from '../truckWorkflow'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const purchaseSummary = {
@@ -93,7 +92,7 @@ describe('platform overseas business API adapter', () => {
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { version: 4, remark: '内部' }, { params: { customerId: '10001' } })
})
it('supports platform truck list/detail/create/update', async () => {
it('supports platform truck list/detail/create/update without status endpoints', async () => {
const detail = { summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail))
expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45')
@@ -107,32 +106,6 @@ describe('platform overseas business API adapter', () => {
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '9007199254741999' } })
})
it.each<TruckOperation>([
{ command: 'review', payload: { version: 2, idempotencyKey: 'review-key', decision: 'APPROVE' } },
{ command: 'review', payload: { version: 2, idempotencyKey: 'reject-key', decision: 'REJECT', remark: '资料不全' } },
{ command: 'receive-pallets', payload: { version: 2, idempotencyKey: 'receive-key', actualPalletCount: 3, packageCount: 10 } },
{ command: 'review-pallet-overage', payload: { version: 2, idempotencyKey: 'overage-key', decision: 'APPROVE' } },
{ command: 'allocate-pallets', payload: { version: 2, idempotencyKey: 'allocate-key', palletIds: ['9007199254741001'] } },
...(['prepare-dispatch', 'dispatch', 'start-transit', 'complete'] as const).map(command => ({ command, payload: { version: 2, idempotencyKey: command } })),
{ command: 'cancel', payload: { version: 2, idempotencyKey: 'cancel-key', status: 'CANCELLED', remark: '客户取消' } },
])('sends the scoped named truck command $command with version and unchanged replay key', async operation => {
const response = envelope({ summary: { ...truckSummary, version: '3', palletCount: '2', packageCount: '10', receivedPalletCount: '1', allocatedPalletCount: '0' }, pallets: [{ id: '9007199254741001', warehousePalletId: null }], histories: [] })
overseas.post.mockResolvedValue(response); overseas.patch.mockResolvedValue(response)
const result = await executeUsTruckCommand('TR/1', '9007199254741999', operation)
const mutation = operation.command === 'cancel' ? overseas.patch : overseas.post
expect(mutation).toHaveBeenCalledWith(`/platform/v1/last-leg/us-truck-orders/TR%2F1/${operation.command === 'cancel' ? 'status' : operation.command}`, operation.payload, { params: { customerId: '9007199254741999' } })
expect(result).toMatchObject({ version: 3, palletCount: 2, receivedPalletCount: 1, allocatedPalletCount: 0 })
expect(result.pallets[0].id).toBe('9007199254741001')
expect(operation.command === 'cancel' ? overseas.post : overseas.patch).not.toHaveBeenCalled()
})
it('propagates a truck command version conflict without fallback status writes', async () => {
overseas.post.mockResolvedValue({ code: 'A0409', message: '版本冲突', data: null, requestId: 'TRUCK-409' })
await expect(executeUsTruckCommand('TR-1', '10002', { command: 'complete', payload: { version: 2, idempotencyKey: 'original' } })).rejects.toMatchObject({ code: 'A0409', requestId: 'TRUCK-409' })
expect(overseas.post).toHaveBeenCalledTimes(1)
expect(overseas.patch).not.toHaveBeenCalled()
})
it('supports looseboard list/detail/create and PUT update', async () => {
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
@@ -1,5 +1,4 @@
import type { PlatformProcurement } from '../procurementTypes'
import type { TruckOperation } from '../truckWorkflow'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
@@ -33,15 +32,15 @@ interface LooseboardDetailPayload {
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
}
function procurementInteger(value: unknown, business = '采购'): number {
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`${business}数据中的数量或版本无效,请刷新后重试`)
function procurementInteger(value: unknown): number {
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('采购数据中的数量或版本无效,请刷新后重试')
return Number(value)
}
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
return { ...value, version: procurementInteger(value.version), purchaseQuantity: procurementInteger(value.purchaseQuantity), id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
}
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
return { ...value, id: String(value.id), customerId: String(value.customerId), version: procurementInteger(value.version, '卡车'), palletCount: procurementInteger(value.palletCount, '卡车'), packageCount: procurementInteger(value.packageCount, '卡车'), receivedPalletCount: procurementInteger(value.receivedPalletCount, '卡车'), allocatedPalletCount: procurementInteger(value.allocatedPalletCount, '卡车'), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
}
function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] }
@@ -131,14 +130,6 @@ export async function updateUsTruckOrder(orderNo: string, customerId: string, pa
const response = await http.patch<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return truckDetail(unwrapApiResponse(response))
}
/** 正向履约使用具名命令,通用状态接口仅用于取消。 */
export async function executeUsTruckCommand(orderNo: string, customerId: string, operation: TruckOperation) {
const path = `${truckBasePath}/${encodeURIComponent(orderNo)}/${operation.command === 'cancel' ? 'status' : operation.command}`
const response = operation.command === 'cancel'
? await http.patch<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(path, operation.payload, { params: { customerId } })
: await http.post<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(path, operation.payload, { params: { customerId } })
return truckDetail(unwrapApiResponse(response))
}
export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) {
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined } })
return normalizePage(unwrapApiResponse(response), looseboardSummary)
@@ -1,118 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { executeUsTruckCommand } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import { availableTruckCommands, truckCommandHints, truckCommandLabels, type TruckCommand, type TruckOperation } from '../truckWorkflow'
import type { LocalTransportDetail } from '../types'
const props = defineProps<{ detail: LocalTransportDetail }>()
const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>()
const access = useAccessPreviewStore()
const commands = computed(() => access.can(overseasBusinessPermissions.truckStatus) ? availableTruckCommands(props.detail) : [])
const open = ref(false), saving = ref(false)
const active = ref<TruckCommand | null>(null)
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
// 响应丢失时保留原命令、版本和幂等键,重试不得变更请求内容。
const pending = ref<TruckOperation | null>(null)
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
watch(saving, value => emit('busy', value))
watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version], () => {
pending.value = null; open.value = false; active.value = null; error.value = null
})
function start(command: TruckCommand) {
if (saving.value || !commands.value.includes(command)) return
if (pending.value) {
active.value = pending.value.command; open.value = true
return
}
active.value = command; error.value = null; validationError.value = ''
Object.assign(form, { decision: 'APPROVE', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
open.value = true; captureBaseline()
}
function buildOperation(): TruckOperation | null {
if (!active.value) return null
const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined }
if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) {
validationError.value = '请填写拒绝或取消原因'; return null
}
switch (active.value) {
case 'review': case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
case 'receive-pallets':
if (!Number.isSafeInteger(form.actualPalletCount) || form.actualPalletCount < Math.max(1, props.detail.receivedPalletCount)) {
validationError.value = '累计实收托数须为正整数,且不能少于已实收托数'; return null
}
if (form.packageCount != null && (!Number.isSafeInteger(form.packageCount) || form.packageCount < 0)) {
validationError.value = '实收件数须为非负整数'; return null
}
return { command: active.value, payload: { ...base, actualPalletCount: form.actualPalletCount, ...(form.packageCount == null ? {} : { packageCount: form.packageCount }) } }
case 'allocate-pallets':
if (!form.palletIds.length || form.palletIds.length > 500 || form.palletIds.some(id => !selectablePallets.value.some(pallet => pallet.id === id))) {
validationError.value = '请选择本单已实收且未分配的托盘,每次最多分配 500 托'; return null
}
return { command: active.value, payload: { ...base, palletIds: [...form.palletIds] } }
case 'cancel': return { command: active.value, payload: { ...base, status: 'CANCELLED' } }
default: return { command: active.value, payload: base }
}
}
async function submit() {
if (saving.value || !active.value || !commands.value.includes(active.value)) return
validationError.value = ''; error.value = null
const operation = pending.value ?? buildOperation()
if (!operation) return
pending.value = operation; saving.value = true
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
try {
const updated = await executeUsTruckCommand(orderNo, customerId, operation)
if (props.detail.customerId !== customerId || props.detail.orderNo !== orderNo) return
pending.value = null; open.value = false; captureBaseline()
ElMessage.success(`${truckCommandLabels[operation.command]}成功`)
emit('updated', updated)
} catch (cause) {
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
} finally { saving.value = false }
}
</script>
<template>
<div v-if="commands.length" class="truck-order-actions">
<el-button v-for="command in commands" :key="command" :type="command === 'cancel' ? 'danger' : 'primary'" :plain="command === 'cancel'" :disabled="saving" @click="start(command)">{{ truckCommandLabels[command] }}</el-button>
<p v-if="pending && !open" role="status">上次操作尚未确认结果,请重新打开重试,或刷新订单核对最新记录。</p>
</div>
<AppDialog v-model="open" class="admin-density-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="pending ? '重试原操作' : '确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
<template v-if="active">
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可重试原操作;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<template v-if="active === 'receive-pallets'">
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" placeholder="请输入累计实收托数" /></el-form-item>
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" placeholder="选填" /></el-form-item>
</template>
<el-form-item v-if="active === 'allocate-pallets'" label="分配托盘" required>
<el-checkbox-group v-model="form.palletIds"><el-checkbox v-for="pallet in selectablePallets" :key="pallet.id" :value="pallet.id">{{ pallet.palletNo }}({{ pallet.packageCount ?? '未登记' }} 件)</el-checkbox></el-checkbox-group>
<p v-if="!selectablePallets.length">暂无可分配托盘,请刷新核对实收记录。</p>
</el-form-item>
<el-form-item :label="review ? '审核意见' : '操作说明'" :required="active === 'cancel' || (review && form.decision === 'REJECT')"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入审核意见或操作说明,拒绝或取消时必填" show-word-limit /></el-form-item>
</el-form>
</template>
</AppDialog>
</template>
<style scoped>
.truck-order-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.truck-order-actions p { flex-basis: 100%; margin: 0; color: var(--cc-muted); }
</style>
@@ -295,14 +295,6 @@ describe('overseas business mounted pages', () => {
})
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('opens the requested truck review from the list using its customer scope', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
await wrapper.vm.$nextTick()
const review = wrapper.findAll('button').find(button => button.text() === '审核')
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } })
})
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { inquiryReturnTarget, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -11,8 +11,6 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { truckStatusLabels } from '../truckWorkflow'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -39,6 +37,7 @@ const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const fields = computed<QueryField[]>(() => [
@@ -47,7 +46,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
])
const truckColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'vehicleType', label: '车辆类型', minWidth: 110, formatter: (row: LocalTransportOrder) => row.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[row.vehicleType] : '—' },
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
@@ -98,20 +97,6 @@ function search(model: QueryModel) { applied.value = { ...model }; if (page.valu
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function handleTruckAction(key: string, row: LocalTransportOrder) {
if (key === 'edit') openEdit(row)
else if (key === 'arrangement') showArrangement(row)
else if (key === 'detail') void router.push({ name: 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
}
let activatedOnce = false
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route.query.orderNo], () => { page.value = 1; applied.value = { orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
</script>
@@ -126,7 +111,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><RowActions v-if="!isLooseboard" :actions="truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder)" /><div v-else class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
</DataTable></section>
@@ -1,194 +0,0 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalTransportDetail } from '../types'
import TruckOrderDetailView from './TruckOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'TR-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ getUsTruckOrder: vi.fn(), executeUsTruckCommand: vi.fn() }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
function order(overrides: Partial<LocalTransportDetail> = {}): LocalTransportDetail {
return {
id: '9007199254741001', orderNo: 'TR-TEST', customerId: '9007199254741999', fleetArrangement: null,
vehicleType: 'PALLET_15', origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址',
originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00',
palletCount: 0, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, packageCount: 20,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_REVIEW', version: 1, remark: '运输备注', createdBy: '7', createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00',
pallets: [], histories: [], ...overrides,
}
}
function mountDetail(manage = true) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true } } })
}
async function click(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find(item => item.text() === label)
expect(button, `button ${label}`).toBeDefined()
await button!.trigger('click'); await flushPromises()
}
async function confirm(wrapper: VueWrapper) {
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
}
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
routing.route.params = { orderId: 'TR-TEST' }; routing.route.query = { customerId: '9007199254741999' }
vi.mocked(getUsTruckOrder).mockResolvedValue(order())
})
afterEach(() => vi.unstubAllGlobals())
describe('truck review and fulfillment', () => {
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
let current = order()
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
const next = { ...current, version: current.version + 1 }
switch (operation.command) {
case 'review': next.status = 'WAITING_INBOUND'; break
case 'receive-pallets':
next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2
next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' }))
break
case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break
case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break
case 'dispatch': next.status = 'DISPATCHED'; break
case 'start-transit': next.status = 'IN_TRANSIT'; break
case 'complete': next.status = 'COMPLETED'; break
}
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
current = next; return next
})
const wrapper = mountDetail(); await flushPromises()
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过')
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
expect(current.status).toBe('COMPLETED')
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
})
it('requires a rejection reason and displays the resulting rejected state', async () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
formModel(wrapper).remark = '取送时间需重新确认'
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
})
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
expect(wrapper.text()).toContain('订单版本冲突')
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
await click(wrapper, '审核'); await confirm(wrapper)
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
expect(wrapper.text()).toContain('登记实收')
})
it('locks duplicate submission until the original response arrives', async () => {
let finish!: (value: LocalTransportDetail) => void
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('登记实收')
})
it('validates cumulative receipt totals without submitting invalid input', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收')
formModel(wrapper).actualPalletCount = 1; await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数')
Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
it('requires received pallets and permits partial allocation using their string IDs', async () => {
const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null }
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).palletIds = [pallet.id]
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] }))
await confirm(wrapper)
expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车')
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) }))
})
it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 }))
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 }))
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核')
Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) }))
})
it('requires cancellation reason and submits only the cancellation status', async () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '取消订单'); await confirm(wrapper)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).remark = '客户取消需求'; vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'CANCELLED', version: 2 })); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'cancel', payload: expect.objectContaining({ status: 'CANCELLED', remark: '客户取消需求' }) }))
})
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
const wrapper = mountDetail(false); await flushPromises()
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
it('keeps pool sourced orders on the existing fleet fulfillment path', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ fleetArrangement: {
sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-TEST', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST',
plannedDepartureAt: null, originWarehouseName: 'LA', destinationLabel: 'ONT', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null,
fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null,
deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '按拼车承运实际履约推进',
} }))
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).toContain('按拼车承运实际履约推进')
expect(wrapper.findComponent(TruckOrderActions).exists()).toBe(false)
expect(executeUsTruckCommand).not.toHaveBeenCalled()
})
it('shows a scoped error without requesting detail when customer is missing', async () => {
routing.route.query = {}; const wrapper = mountDetail(); await flushPromises()
expect(getUsTruckOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少有效客户或订单信息')
})
it('retains error metadata and retries a failed detail read', async () => {
vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'DETAIL-409')).mockResolvedValueOnce(order())
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).toContain('读取失败'); expect(wrapper.text()).toContain('DETAIL-409')
await wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.text()).toContain('TR-TEST'); expect(wrapper.text()).toContain('审核与操作记录')
})
})
@@ -1,87 +0,0 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import { truckStatusLabels } from '../truckWorkflow'
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabel = (value: string | null) => value ? truckStatusLabels[value as keyof typeof truckStatusLabels] || value : '—'
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['申报件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
] : [])
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
{ key: 'packageCount', prop: 'packageCount', label: '实收件数', width: 120 },
{ key: 'receivedAt', label: '实收时间', minWidth: 170, formatter: (row: LocalTransportPallet) => dateTime(row.receivedAt) },
]
const operations: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', RECEIVE_PALLETS: '登记实收', REVIEW_PALLET_OVERAGE: '超托审核', ALLOCATE_PALLETS: '分配托盘', PREPARE_DISPATCH: '确认待派车', DISPATCH: '确认已派车', START_TRANSIT: '确认发运', COMPLETE: '确认完成', CANCEL: '取消订单' }
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
]
async function load() {
if (saving.value) return
const sequence = ++requestSequence
detail.value = null; error.value = null
if (!orderNo.value || !customerId.value || customerId.value === '0') {
loading.value = false; error.value = { message: '缺少有效客户或订单信息,请返回本土卡车列表重新打开' }; return
}
loading.value = true
try { const result = await getUsTruckOrder(orderNo.value, customerId.value); if (sequence === requestSequence) detail.value = result }
catch (cause) { if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '本土卡车订单加载失败,请稍后重试') }
finally { if (sequence === requestSequence) loading.value = false }
}
watch([orderNo, customerId], load, { immediate: true })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout aria-label="本土卡车订单详情">
<template #header><div class="truck-detail-header"><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button :disabled="saving" @click="load">刷新订单</el-button></div>
<FleetArrangementSummary v-if="detail.fleetArrangement" :arrangement="detail.fleetArrangement" />
<template v-else><p>审核通过 → 实收托盘 → 分配托盘 → 待派车 → 已派车 → 运输中 → 完成</p><TruckOrderActions :detail="detail" @updated="detail = $event" @busy="saving = $event" /></template>
</section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="truck-detail-section"><h2>托盘记录</h2><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></section>
</template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.truck-detail-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.truck-detail-header h1, .truck-detail-header h2 { margin: 0; }
.truck-detail-section { margin-bottom: 16px; }
</style>
+2 -3
View File
@@ -10,7 +10,6 @@ describe('overseas business routes', () => {
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
['overseas-business/trucks', permission.truckList],
['overseas-business/trucks/new', permission.truckCreate],
['overseas-business/trucks/:orderId', permission.truckList],
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
['overseas-business/looseboards', permission.looseboardList],
['overseas-business/looseboards/new', permission.looseboardCreate],
@@ -18,10 +17,10 @@ describe('overseas business routes', () => {
])
})
it('adds the requested truck detail without opening unrelated routes', () => {
it('does not invent excluded create or detail routes', () => {
const paths = overseasBusinessRoutes.map((route) => route.path)
expect(paths).not.toContain('overseas-business/purchases/new')
expect(paths).toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
})
-6
View File
@@ -34,12 +34,6 @@ export const overseasBusinessRoutes = [
props: { domain: 'truck' },
meta: { title: '新增本土卡车订单', description: '创建海外本土卡车订单', permission: permission.truckCreate, showPageHeading: false },
},
{
path: 'overseas-business/trucks/:orderId',
name: 'overseas-business-truck-detail',
component: () => import('./pages/TruckOrderDetailView.vue'),
meta: { title: '本土卡车订单详情', description: '审核及处理本土卡车订单', permission: permission.truckList, showPageHeading: false },
},
{
path: 'overseas-business/trucks/:orderId/edit',
name: 'overseas-business-truck-edit',
@@ -1,46 +0,0 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = {
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配',
PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
}
export const truckCommandLabels = {
review: '审核', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
'prepare-dispatch': '确认待派车', dispatch: '确认已派车', 'start-transit': '确认发运', complete: '确认完成', cancel: '取消订单',
} as const
export type TruckCommand = keyof typeof truckCommandLabels
export interface TruckCommandBase { version: number; idempotencyKey: string; remark?: string }
export type TruckOperation =
| { command: 'review' | 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
| { command: 'receive-pallets'; payload: TruckCommandBase & { actualPalletCount: number; packageCount?: number; palletPackageCounts?: number[] } }
| { command: 'allocate-pallets'; payload: TruckCommandBase & { palletIds: string[] } }
| { command: 'prepare-dispatch' | 'dispatch' | 'start-transit' | 'complete'; payload: TruckCommandBase }
| { command: 'cancel'; payload: TruckCommandBase & { status: 'CANCELLED' } }
const nextCommands: Partial<Record<LocalTransportStatus, TruckCommand>> = {
PENDING_REVIEW: 'review', WAITING_INBOUND: 'receive-pallets', RECEIVING: 'receive-pallets',
PALLET_OVERAGE_REVIEW: 'review-pallet-overage', WAITING_CONSOLIDATION: 'allocate-pallets',
PARTIALLY_ALLOCATED: 'allocate-pallets', ALLOCATED: 'prepare-dispatch', PENDING_DISPATCH: 'dispatch',
DISPATCHED: 'start-transit', IN_TRANSIT: 'complete',
}
const cancellable = new Set<LocalTransportStatus>(['PENDING_REVIEW', 'WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'WAITING_CONSOLIDATION', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH'])
/** 拼车来源只读,独立单据只能执行当前状态对应的具名命令。 */
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
if (order.fleetArrangement) return []
const next = nextCommands[order.status]
return [...(next ? [next] : []), ...(cancellable.has(order.status) ? ['cancel' as const] : [])]
}
export const truckCommandHints: Record<TruckCommand, string> = {
review: '请核对车型、路线、两端联系信息、取送时间和货物资料。通过后进入待入仓,拒绝后结束本次申请。',
'receive-pallets': '按实际收货填写截至本次的累计实收托数。首次实收确定本单托数,后续超出已确认托数需要超托审核。',
'review-pallet-overage': '通过后将超收托盘纳入本单;拒绝后将超收托盘标记为拒收,按原已确认托数继续。',
'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。',
'prepare-dispatch': '确认全部已接收托盘已分配完成,将本单交由调度安排车辆。',
dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。',
'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。',
complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。',
cancel: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。',
}
@@ -3,12 +3,7 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
const response = await http.get<ApiEnvelope<CargoDiscardStatistics>, ApiEnvelope<CargoDiscardStatistics>>('/platform/v1/last-leg/cargo-discards/statistics', { params: query })
return unwrapApiResponse(response)
}
export interface CargoDiscardItem { cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
@@ -52,7 +52,6 @@ export interface RelabelWarehouseOptionDto {
}
export interface RelabelSummaryDto {
operationTypes?: Array<'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'>
id: string
relabelNo: string
lastlegOrderId?: string | null
@@ -94,7 +93,7 @@ export interface RelabelSummaryDto {
export interface RelabelItemDto {
id: string
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
skuCode?: string | null
productNameZh?: string | null
@@ -130,30 +129,6 @@ export interface RelabelDetailDto {
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null
}
export interface RelabelQuantity {
labelPieceCount: number | string | null
cartonCount: number | string | null
skuCount: number | string
duplicateCarton: boolean
quantityUnit: string
sourcePalletCount: number | string | null
targetPalletCount: number | string | null
}
export interface RelabelQuantityTotals {
orderCount: number | string
skuCount: number | string
knownLabelPieceCount: number | string
unknownPieceOrders: number | string
knownCartonCount: number | string
unknownCartonOrders: number | string
orders: Record<string, RelabelQuantity>
}
/** 汇总同一筛选下全部单据,不以当前页合计代替。 */
export async function getRelabelQuantities(query: RelabelOrderQuery) {
const response = await http.get<ApiEnvelope<RelabelQuantityTotals>, ApiEnvelope<RelabelQuantityTotals>>('/platform/v1/last-leg/relabel-applications/quantity-summary', { params: query })
return unwrapApiResponse(response)
}
export interface ReturnPackingPlanBoxItem {
skuCode: string
quantity: number
@@ -258,14 +233,8 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string
return unwrapApiResponse(response)
}
export interface RelabelReviewTask {
version: number
taskStatus: string
items?: Array<{ sourcePalletCode?: string | null; sourcePalletBasis?: string }>
history?: Array<{ action: string; detail?: string }>
}
export async function getRelabelReviewTask(relabelNo: string) {
const response = await http.get<ApiEnvelope<RelabelReviewTask>, ApiEnvelope<RelabelReviewTask>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`)
const response = await http.get<ApiEnvelope<{ version: number; taskStatus: string }>, ApiEnvelope<{ version: number; taskStatus: string }>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`)
return unwrapApiResponse(response)
}
@@ -5,11 +5,10 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnCartonDiscardHistory { discardNo: string; skuCode: string; productNameZh?: string; beforeQuantity: number; quantity: number; afterQuantity: number; operatorId?: string; occurredAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[]; discardHistory?: ReturnCartonDiscardHistory[] }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] }
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND' | 'DISCARD'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string }
export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
@@ -7,12 +7,13 @@ import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import * as api from '../api/dispatches'
import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
interface State { submit(): 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 }
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
const [first, second] = sharedPalletCandidates()
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
@@ -155,3 +156,81 @@ it('refuses already picked item removal and a route that drops an authoritative
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 () => {
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.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('saved')).toBeUndefined()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
wrapper.unmount()
})
it('retains edited values and the original validation response when the action is still allowed', async () => {
const { wrapper, state } = await open('EDIT'); state.form.remark = '用户未保存的本轮备注'
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('预计到达时间须在发车之后', 'A0400', 400, undefined, 'validation-request'))
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(wrapper.text()).toContain('预计到达时间须在发车之后')
expect(wrapper.text()).toContain('A0400')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await state.retryError()
expect(state.form.remark).toBe('用户未保存的本轮备注')
expect(api.updateDispatch).toHaveBeenCalledTimes(1)
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 () => {
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'))
await state.submit(); await flushPromises()
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(state.form.remark).toBe('保留本次备注')
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps an unknown original command when its later replay is rejected instead of treating it as a fresh rejection', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown transport outcome')).mockRejectedValueOnce(new ApiResponseError('当前已提交', 'A0409', 409))
await state.submit()
const original = structuredClone(vi.mocked(api.submitDispatchReview).mock.calls[0]![1])
expect(wrapper.text()).toContain('原操作结果尚待核对')
vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('未找到命令', 'A0404', 404))
await state.submit(); await flushPromises()
expect(api.submitDispatchReview).toHaveBeenNthCalledWith(2, 'PC/1', original)
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.body).toEqual(original)
expect(wrapper.emitted('rejected')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(api.getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('原操作结果尚待核对')
wrapper.unmount()
})
it('describes a proven success with a failed GET as completed and retries only its read', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
await state.submit(); await flushPromises()
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result).toMatchObject({ action: 'SUBMIT_REVIEW', recordVersion: 1 })
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.text()).not.toContain('核对并重试原请求')
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
@@ -14,14 +14,14 @@ import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candi
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>()
const emit = defineEmits<{ saved: [] }>()
const emit = defineEmits<{ saved: []; rejected: [details: ApiErrorDetails] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const { pending, busy } = recovery
@@ -30,6 +30,7 @@ const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const factsStale = ref(false)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
@@ -40,7 +41,7 @@ const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver()
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
let editorRequestKey = ''
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const allowed = computed(() => !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
@@ -49,7 +50,7 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
loading.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
items.value = null; itemId.value = ''; route.value = null; stops.value = []
try {
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
@@ -207,17 +208,46 @@ async function submit() {
if (!result) return
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '操作结果尚未确认,请保留原请求核对') }
} catch (cause) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
// Recovery clears only a fresh, definite rejection. A later refusal must not erase an unknown write.
if (!pending.value && isDefiniteDispatchRejection(cause)) {
editorRequestKey = ''
emit('rejected', error.value)
await refreshFacts()
}
}
}
async function refreshFacts() {
const sequence = ++generation, no = currentNo.value
loading.value = true; factsStale.value = true
try {
const value = await api.getDispatch(no)
if (sequence !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
if (currentOperation.value === 'ROUTE') {
const data = await api.getDispatchRoute(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('路线与原排车单不一致')
route.value = data
}
factsStale.value = false
if (!pending.value && !allowed.value) open.value = false
} catch (cause) {
// Retain the original rejection and every editor value if its follow-up read also fails.
if (sequence === generation && !error.value) error.value = getApiErrorDetails(cause, '当前操作资格读取失败,请重试刷新')
} finally { if (sequence === generation) loading.value = false }
}
async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() }
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
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" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="pending" role="status" title="原操作结果尚待核对,当前使用原版本和原请求内容。" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending?.rejected" :disabled="busy || loading || uploadingEvidence" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
<el-alert v-if="pending" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
@@ -35,19 +35,6 @@ function fixture(): WarehouseTransferDetailDto {
beforeEach(() => { can.mockReset().mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset(); vi.mocked(increaseWarehouseTransferForecast).mockReset() })
afterEach(() => vi.restoreAllMocks())
describe('转运预报与实际签收明细', () => {
it('keeps historical receipt totals after discard or shipment and leaves unknown declaration differences blank', async () => {
const value = fixture()
value.items[0]!.quantity = null
value.cartons[0]!.status = 'DISCARD_PENDING'
value.cartons[1]!.status = 'SHIPPED'
value.cartons[2]!.status = 'DISCARDED'
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = render(); await flushPromises()
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 3, quantity: 27, skus: 2, forecastQuantity: null })
const rows = wrapper.findAllComponents(DataTableStub)[0]!.props('data')
expect(rows[0]).toMatchObject({ receivedQuantity: 20, receivedBoxes: 2, quantityDifference: null })
expect(wrapper.findAll('.test-table')[0]!.text()).toContain('未记录')
})
it('shows every SKU in a mixed box and counts its physical measurements once', async () => {
const value = fixture()
value.cartons = [{ ...value.cartons[0]!, quantity: 10, items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }]
@@ -53,8 +53,7 @@ const cartonsOpen = ref(false)
let generation = 0
// 同一完成原因保存于箱记录,去重后在尾程详情直接展示。
const discrepancyReasons = computed(() => [...new Set((detail.value?.cartons || []).map(box => box.discrepancyReason?.trim()).filter((reason): reason is string => Boolean(reason)))])
// 已签收是历史事实,后续废弃占用、废弃或出库不能从实收汇总中移除。
const cartons = computed(() => (detail.value?.cartons || []).filter(box => ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].includes(box.status)))
const cartons = computed(() => (detail.value?.cartons || []).filter(box => box.status === 'COUNTED'))
function itemFor(box: WarehouseTransferCartonDto) { return detail.value?.items.find(item => box.transferItemId != null && String(item.id) === String(box.transferItemId)) }
function boxLines(box: WarehouseTransferCartonDto) {
if (box.items?.length) return box.items
@@ -72,10 +71,9 @@ watch([detail, totals], () => emit('summary', detail.value ? { ...totals.value,
const comparisonRows = computed(() => (detail.value?.items || []).map(item => {
const boxes = cartons.value.filter(box => boxLines(box).some(line => String(line.transferItemId) === String(item.id)))
const receivedQuantity = boxes.reduce((sum, box) => sum + boxLines(box).filter(line => String(line.transferItemId) === String(item.id)).reduce((total, line) => total + Number(line.quantity), 0), 0)
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: item.quantity == null ? null : receivedQuantity - Number(item.quantity), boxDifference: boxes.length - Number(item.packageCount || 0) }
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: receivedQuantity - Number(item.quantity || 0), boxDifference: boxes.length - Number(item.packageCount || 0) }
}))
function differenceText(value: unknown) {
if (value == null) return '未记录'
if (!cartons.value.length) return '待签收'
const difference = Number(value)
return difference > 0 ? `+${difference}` : String(difference)
@@ -19,8 +19,12 @@ it('keeps a proven result across GET failure and does not send the command a sec
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
await recovery.execute(send, read, find)
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
const reopened = useDispatchCommandRecovery(() => owner)
expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
await reopened.execute(send, read, find)
expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
expect(reopened.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
@@ -38,6 +42,12 @@ it('does not discard an earlier unknown write on a later business rejection', as
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
expect(find).toHaveBeenCalledWith('PC/1', 'key')
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key' })
expect(stable.pending.value?.rejected).toBe(false)
const saved = JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!)
expect(saved).toMatchObject({ attempted: true, rejected: false, result: null, body: { recordVersion: 0, idempotencyKey: 'key' } })
expect(useDispatchCommandRecovery(() => owner).pending.value).toEqual(saved)
})
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
@@ -61,3 +71,66 @@ it('treats an internal server error as unknown instead of authorizing a new comm
await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
})
it.each(['400', '401', '403', '404', '409', '410', '422'].flatMap(code => [code, `A0${code}`, `HTTP_${code}`]))(
'clears the first definite rejection %s from memory and storage and preserves its original error', async code => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'rejected-key' })
const rejection = new ApiResponseError('明确拒绝', code)
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn(), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
expect(send).toHaveBeenCalledTimes(1)
expect(read).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled()
expect(recovery.pending.value).toBeNull(); expect(recovery.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
const reopened = useDispatchCommandRecovery(() => owner)
expect(reopened.pending.value).toBeNull()
expect(() => reopened.begin('PC/1', 'EDIT', { recordVersion: 2, idempotencyKey: 'fresh-key' })).not.toThrow()
},
)
it('cleans an explicitly rejected legacy browser record when its scope is restored', () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
sessionStorage.setItem(key, JSON.stringify({ dispatchNo: 'PC/1', action: 'SUBMIT_REVIEW',
body: { recordVersion: 0, idempotencyKey: 'terminal-key' }, attempted: true, rejected: true, result: null }))
const recovery = useDispatchCommandRecovery(() => owner)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 1, idempotencyKey: 'new-key' })).not.toThrow()
})
it('cleans a terminal rejected record already shared by dialogs in the same scope', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner)
first.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'terminal-key' })
first.pending.value!.attempted = true; first.pending.value!.rejected = true
sessionStorage.setItem(`dispatch-command:${owner}`, JSON.stringify(first.pending.value))
const second = useDispatchCommandRecovery(() => owner)
expect(second.pending).toBe(first.pending)
expect(first.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
it('retains a restored unknown record without explicit rejection proof and reconciles its original key', async () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
const original = { dispatchNo: 'PC/1', action: 'EDIT', body: { recordVersion: 0, idempotencyKey: 'unknown-key', remark: 'original' },
attempted: true, rejected: false, result: null }
sessionStorage.setItem(key, JSON.stringify(original))
const recovery = useDispatchCommandRecovery(() => owner)
expect(recovery.pending.value).toEqual(original)
const rejection = new ApiResponseError('later rejection', 'A0409')
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn()
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
expect(find).toHaveBeenCalledWith('PC/1', 'unknown-key')
expect(send.mock.calls[0]![0].body).toEqual(original.body)
expect(read).not.toHaveBeenCalled()
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
})
it('only rereads a restored proven result and keeps it when the detail read still fails', async () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
const original = { dispatchNo: 'PC/1', action: 'LOADING_COMPLETE', body: { recordVersion: 0, idempotencyKey: 'key' },
attempted: true, rejected: false, result: command('LOADING_COMPLETE') }
sessionStorage.setItem(key, JSON.stringify(original))
const recovery = useDispatchCommandRecovery(() => owner)
const send = vi.fn(), find = vi.fn(), failure = new Error('detail unavailable')
const read = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 })
await expect(recovery.execute(send, read, find)).rejects.toBe(failure)
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
await recovery.execute(send, read, find)
expect(send).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
})
@@ -64,7 +64,9 @@ export function useDispatchCommandRecovery(scope: () => string) {
try { const sent = await send(snapshot(command)); validateCommandResult(sent, command); command.result = sent }
catch (error) {
// A later rejection cannot erase an earlier unknown write result.
if (!previouslyAttempted && isDefiniteDispatchRejection(error)) command.rejected = true
if (!previouslyAttempted && isDefiniteDispatchRejection(error)) {
pending.value = null
}
persist(); throw error
}
}
@@ -75,8 +77,11 @@ export function useDispatchCommandRecovery(scope: () => string) {
} finally { busy.value = false }
}
function discardRejected() {
if (busy.value || !pending.value?.rejected) return
if (busy.value || pending.value?.rejected !== true) return
pending.value = null; persist()
}
// Older browser records explicitly marked a first-attempt rejection as terminal.
// Only that recorded proof permits cleanup; unknown attempts and proven results remain.
discardRejected()
return { pending, busy, begin, execute, discardRejected }
}
@@ -8,48 +8,202 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const statistics = ref<CargoDiscardStatistics | null>(null)
/** 数量事实缺失时明确提示,不用零代替未记录。 */
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const access = useAccessPreviewStore()
const rows = ref<CargoDiscardSummary[]>([])
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const customersLoading = ref(false)
const listError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const auditingNo = ref('')
const detailOpen = ref(false)
const detail = ref<CargoDiscardDetail | null>(null)
const detailRequest = ref<Pick<CargoDiscardSummary, 'discardNo' | 'customerId'> | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', options: [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))], props: { disabled: customersLoading.value || Boolean(customersError.value), filterable: true } },
{ key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 },
{ key: 'status', type: 'select', label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
const photoUrls = reactive<Record<string, string>>({})
const photoLoading = reactive<Record<string, boolean>>({})
const photoErrors = reactive<Record<string, ApiErrorDetails | undefined>>({})
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
let photoGeneration = 0
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
let detailGeneration = 0
let listGeneration = 0
function clearPhotos() {
photoGeneration++
Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url))
for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key])
}
async function loadPhoto(fileId: string) {
const current = detail.value; const generation = photoGeneration
if (!current || photoLoading[fileId]) return
photoLoading[fileId] = true; photoErrors[fileId] = false
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === photoGeneration) photoErrors[fileId] = true }
const current = detail.value
const generation = photoGeneration
if (!current || !detailOpen.value || photoLoading[fileId] || !access.can(permission.discardList)) return
photoLoading[fileId] = true; delete photoErrors[fileId]
try {
const customerId = String(current.summary.customerId ?? '').trim()
if (!customerId) { photoErrors[fileId] = getApiErrorDetails(null, '照片缺少客户范围,无法读取'); return }
const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, customerId)
if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return
if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId])
photoUrls[fileId] = URL.createObjectURL(blob)
} catch (error) { if (generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败,请重试') }
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
}
watch(detailOpen, value => { if (!value) clearPhotos() })
onBeforeUnmount(clearPhotos)
watch(detailOpen, value => {
if (value) return
detailGeneration++; detailLoading.value = false; detailRequest.value = null; detail.value = null; detailError.value = null; clearPhotos()
})
onBeforeUnmount(() => { detailGeneration++; listGeneration++; clearPhotos() })
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
/** 同一筛选读取分页与全量统计,失败时清除旧数据,避免陈旧合计。 */
let loadGeneration = 0
async function load(model: QueryModel = filters.value) { const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; try { const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }; const [result, summary] = await Promise.all([listCargoDiscards(query), getCargoDiscardStatistics(query)]); if (generation !== loadGeneration) return; rows.value = result.records; total.value = result.total; statistics.value = summary } catch (e) { if (generation !== loadGeneration) return; rows.value = []; total.value = 0; statistics.value = null; ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { if (generation === loadGeneration) loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
async function loadCustomers() {
if (customersLoading.value) return
customersLoading.value = true; customersError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const generation = ++listGeneration
loading.value = true; listError.value = null
try {
const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') })
if (generation !== listGeneration) return
rows.value = result.records; total.value = result.total
} catch (error) {
if (generation !== listGeneration) return
rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
} finally { if (generation === listGeneration) loading.value = false }
}
async function openDetail(row: CargoDiscardSummary) {
if (detailOpen.value || detailLoading.value || auditingNo.value || !access.can(permission.discardList)) return
detailRequest.value = { discardNo: row.discardNo, customerId: row.customerId }
detail.value = null; detailError.value = null; detailOpen.value = true
await loadDetail()
}
async function loadDetail() {
const request = detailRequest.value
if (!request || !detailOpen.value || detailLoading.value || !access.can(permission.discardList)) return
const generation = ++detailGeneration
detailLoading.value = true; detailError.value = null; detail.value = null; clearPhotos()
try {
const result = await getCargoDiscard(request.discardNo, request.customerId || undefined)
if (generation !== detailGeneration || !detailOpen.value) return
detail.value = result
void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId)))
} catch (error) { if (generation === detailGeneration && detailOpen.value) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') }
finally { if (generation === detailGeneration) detailLoading.value = false }
}
function canAudit(row: CargoDiscardSummary) { return access.can(permission.discardApprove) && row.status === 'PENDING_REVIEW' }
async function audit(row: CargoDiscardSummary) {
if (!canAudit(row) || auditingNo.value || detailOpen.value || detailLoading.value) return
auditingNo.value = row.discardNo; actionError.value = null
try {
await ElMessageBox.confirm(`确认审核废弃单“${row.discardNo}”?审核通过后将推送 PDA 执行废弃,确认继续?`, '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm', type: 'warning' })
if (!canAudit(row)) return
await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined)
ElMessage.success('已审核通过并推送 PDA'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '审核失败') }
finally { auditingNo.value = '' }
}
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="出库单号">{{ detail.summary.outboundNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column label="申请数量" width="120"><template #default="{ row }">{{ row.discardPackages }} {{ row.quantityUnit || '单位未记录' }}</template></el-table-column><el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column><el-table-column label="所选箱唛" min-width="180"><template #default="{ row }">{{ row.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</template></el-table-column></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
<AdminPage class="admin-density">
<ListPageLayout aria-label="废弃单列表"><section>
<DataTable appearance="integrated" :stripe="false" :results-visible="!listError" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>废弃单</h1></template>
<template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template>
<template #notices>
<LoadErrorState v-if="customersError" title="客户列表加载失败" :message="customersError.message" :loading="customersLoading" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState>
<LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="审核失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions">
<el-button v-if="access.can(permission.discardList)" class="density-action" data-tone="neutral" size="small" :disabled="detailOpen || detailLoading || Boolean(auditingNo)" @click="openDetail(row as CargoDiscardSummary)">详情</el-button>
<el-button v-if="canAudit(row as CargoDiscardSummary)" class="density-action" data-tone="success" size="small" :disabled="detailOpen || detailLoading || Boolean(auditingNo)" :loading="auditingNo === (row as CargoDiscardSummary).discardNo" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button>
</div></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '—'" /></template>
<template #pagination-left>共 {{ total }} 条废弃单</template>
</DataTable>
</section></ListPageLayout>
</AdminPage>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="`废弃单详情 · ${detailRequest?.discardNo || '—'}`" fullscreenable :show-confirm="false" cancel-text="关闭" :loading="detailLoading" :confirm-loading="detailLoading" width="min(920px, calc(100vw - 28px))">
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取废弃单详情…</p>
<LoadErrorState v-if="detailError" title="废弃单详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="废弃单基础资料">
<table class="density-table discard-summary"><tbody>
<tr><th scope="row">废弃单号</th><td><LineClampText :content="detail.summary.discardNo" /></td><th scope="row">状态</th><td>{{ statusLabels[String(detail.summary.status)] || detail.summary.status || '—' }}</td></tr>
<tr><th scope="row">仓库</th><td><LineClampText :content="detail.summary.overseasWarehouseName" /></td><th scope="row">出库单号</th><td><LineClampText :content="detail.summary.outboundNo" /></td></tr>
<tr><th scope="row">入仓码</th><td colspan="3"><LineClampText :content="detail.summary.inboundCodes" :lines="2" /></td></tr>
<tr><th scope="row">备注</th><td colspan="3"><LineClampText :content="detail.remark" :lines="2" /></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="废弃明细">
<h3 class="density-dialog-section__title">废弃明细</h3>
<el-table :data="detail.items" border :stripe="false" row-key="id">
<el-table-column prop="inboundCode" label="入仓码" min-width="140"><template #default="{ row }"><LineClampText :content="row.inboundCode" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="140"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="productName" label="品名" min-width="160"><template #default="{ row }"><LineClampText :content="row.productName" /></template></el-table-column>
<el-table-column prop="discardPackages" label="申请数量" width="100" />
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、')" /></template></el-table-column>
</el-table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="废弃现场照片">
<h3 class="density-dialog-section__title">废弃现场照片</h3>
<div v-if="detail.photos.length" class="discard-photo-grid">
<div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :alt="photo.fileName || '废弃现场照片'" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
<div v-else class="discard-photo-stage">
<span v-if="photoLoading[photo.fileId]" role="status">照片加载中…</span>
<LoadErrorState v-else-if="photoErrors[photo.fileId]" compact title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" retry-text="重新加载照片" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button>
</div>
<small>托盘:{{ photo.palletNo || '—' }} · {{ photo.submittedAt || '—' }}</small>
</div>
</div>
<p v-else class="density-dialog-note">暂无已提交的废弃现场照片</p>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="废弃状态流转">
<h3 class="density-dialog-section__title">状态流转</h3>
<table class="density-table"><thead><tr><th>时间</th><th>状态</th><th>备注</th></tr></thead><tbody>
<tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
<tr v-if="!detail.histories.length"><td colspan="3">暂无状态流转记录</td></tr>
</tbody></table>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height:150px;border-radius:6px}.discard-photo-item small{color:var(--cc-text-secondary)}
.discard-summary { table-layout: fixed; }
.discard-summary th { width: 100px; }
.discard-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.discard-photo-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.discard-photo-item .el-image { width: 100%; height: 150px; }
.discard-photo-stage { display: grid; align-items: center; min-height: 150px; }
</style>
@@ -17,7 +17,7 @@ vi.mock('@/modules/overseas-master-data', async () => ({
inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
}))
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
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 }]
@@ -104,4 +104,45 @@ describe('whole-dispatch four-tab detail', () => {
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
})
it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交调度审核')!.trigger('click')
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } }))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' })
else dialog.vm.$emit('saved')
dialog.vm.$emit('update:modelValue', false)
await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(2)
expect(wrapper.findAll('button').some(button => button.text() === '提交调度审核')).toBe(false)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('detail-reject-request')
}
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
})
it('retains a workflow rejection when the parent detail refresh also fails', async () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed'))
wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' })
await flushPromises()
expect(wrapper.text()).toContain('已提交,请刷新')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('排车详情加载失败')
wrapper.unmount()
})
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
expect(wrapper.text()).toContain('请补齐本趟资料')
dialog.vm.$emit('saved'); await flushPromises()
expect(wrapper.text()).not.toContain('请补齐本趟资料')
wrapper.unmount()
})
})
@@ -21,6 +21,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
const detailActions: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
@@ -65,13 +66,16 @@ async function load() {
finally { if (current === generation) loading.value = false }
}
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
watch(dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
@@ -85,7 +89,7 @@ onBeforeUnmount(() => { generation++ })
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></el-tab-pane>
</el-tabs>
</div>
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
</OverseasDetailShell>
</template>
@@ -6,7 +6,7 @@ import DispatchesView from './DispatchesView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { detail, listRow, page } from '../dispatchTestFixtures'
import { command, detail, listRow, page } from '../dispatchTestFixtures'
import type { DispatchPage } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
@@ -69,3 +69,45 @@ it('navigates by the business number without arranging a separate trip', async (
it('reads one BOL on demand and preserves popup failure handling', async () => {
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
else dialog.vm.$emit('saved')
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(listDispatches).toHaveBeenCalledTimes(2)
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('list-reject-request')
}
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
})
it('describes a proven command awaiting its GET as completed instead of unknown', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'known-key' })
recovery.pending.value!.attempted = true
recovery.pending.value!.result = command('SUBMIT_REVIEW', 'known-key')
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(wrapper.text()).not.toContain('结果尚待核对')
await wrapper.findAll('button').find(button => button.text() === '刷新页面资料')!.trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
expect(wrapper.text()).toContain('请补齐本趟资料')
dialog.vm.$emit('saved'); await flushPromises()
expect(wrapper.text()).not.toContain('请补齐本趟资料')
wrapper.unmount()
})
@@ -28,6 +28,7 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -78,12 +79,15 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (value === 'bol') return void openBol(row)
rejectionError.value = null
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
operation.value = undefined
const requested = value as DispatchAction
if (!canDispatchAction(row.actions, requested, access.can)) return
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
}
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
function recoverPending() {
const command = recovery.pending.value
if (!command) return
@@ -109,7 +113,8 @@ onBeforeUnmount(() => { sequence++ })
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}结果尚待核对。<el-button link :disabled="recovery.busy.value" @click="recoverPending">核对原请求</el-button></p>
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
@@ -122,6 +127,6 @@ onBeforeUnmount(() => { sequence++ })
</DataTable>
</ListPageLayout></AdminPage>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template>
@@ -45,16 +45,6 @@ async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE')
enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => {
it('counts mixed parcel and box receipts by their physical unit instead of calling both parcels', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL', receivedQuantity: 1, boxCount: 0, signedAt: '2026-10-20T10:00:00' }, { receiptNo: 'B1', unitType: 'BOX', receivedQuantity: 1, boxCount: 1, signedAt: '2026-10-20T10:00:00', length: 30, width: 20, height: 10, weight: 2 }],
cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', countStatus: 'COUNTED', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', countStatus: 'COUNTED', items: [] }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('#tab-return-receipt').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('签收实物明细(1 包裹 · 1 箱)')
expect(wrapper.text()).not.toContain('2 包裹')
expect(wrapper.text()).toContain('长 30 cm · 宽 20 cm · 高 10 cm · 体积 0.006 m³ · 重量 2 kg')
})
it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: status, packageCount: 30 },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
@@ -75,25 +75,10 @@ function isSingleLineBusinessField(field: DetailField) { return field.label ===
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
const returnReceiptUnits = computed(() => {
const receiptTypes = new Map((detail.value?.returnReceipt?.receipts || []).map(receipt => [receipt.receiptNo, receipt.unitType]))
const counts = { parcels: 0, boxes: 0, unknown: 0 }
for (const carton of detail.value?.returnReceipt?.cartons || []) {
const type = receiptTypes.get(carton.receiptNo)
if (type === 'PARCEL') counts.parcels++
else if (type === 'BOX' || type === 'PALLET') counts.boxes++
else counts.unknown++
}
return counts
})
const returnReceiptUnitLabel = computed(() => returnReceiptUnits.value.parcels && !returnReceiptUnits.value.boxes && !returnReceiptUnits.value.unknown ? '包裹' : returnReceiptUnits.value.parcels || returnReceiptUnits.value.unknown ? '实物' : '箱')
const returnReceiptUnitSummary = computed(() => {
const { parcels, boxes, unknown } = returnReceiptUnits.value
return [parcels ? `${parcels} 包裹` : '', boxes ? `${boxes} 箱` : '', unknown ? `${unknown} 个未识别载体` : ''].filter(Boolean).join(' · ') || '0 箱'
})
const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱')
const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => {
const measuredUnit = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && (receipt.unitType === 'PARCEL' || receipt.unitType === 'BOX'))
return { ...box, length: box.length ?? measuredUnit?.length, width: box.width ?? measuredUnit?.width, height: box.height ?? measuredUnit?.height, weight: box.weight ?? measuredUnit?.weight }
const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL')
return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight }
}))
function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) {
if (box.length == null || box.width == null || box.height == null) return '—'
@@ -318,7 +303,7 @@ onMounted(load)
<el-table-column prop="boxCount" label="实收箱数" width="100" />
<el-table-column prop="receivedQuantity" label="实收数量" width="100" />
</el-table>
<h3>签收{{ returnReceiptUnitLabel }}明细({{ returnReceiptUnitSummary }})</h3>
<h3>签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})</h3>
<el-collapse v-if="detail.returnReceipt.cartons?.length">
<el-collapse-item v-for="box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark">
<template #title><span class="return-carton-title"><span>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</span><span class="return-carton-measurements">长 {{ box.length ?? '—' }} cm · 宽 {{ box.width ?? '—' }} cm · 高 {{ box.height ?? '—' }} cm · 体积 {{ returnCartonVolume(box) }} m³ · 重量 {{ box.weight ?? '—' }} kg</span></span></template>
@@ -335,7 +320,7 @@ onMounted(load)
<el-empty v-else description="暂无签收箱号明细" />
<h3>合箱记录</h3>
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '—' }}</template></el-table-column>
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
@@ -5,7 +5,7 @@ import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework, getRelabelQuantities, type RelabelQuantity } from '../api/relabelOrders'
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -22,11 +22,6 @@ const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || ''))
const detail = ref<RelabelDetailDto | null>(null)
const quantity = ref<RelabelQuantity | null>(null)
const currentPallets = ref<string[]>([])
const targetPallets = ref<string[]>([])
const sourceMissing = ref(false)
const quantityMessage = ref('')
const loading = ref(false)
const access = useAccessPreviewStore()
const returning = ref(false)
@@ -81,7 +76,7 @@ watch(previewOpen, visible => { if (!visible) resetPreview() })
watch(orderId, () => { previewOpen.value = false; resetPreview() })
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
{ title: '费用明细', fields: ['换标费总计', '资金异常说明'] },
]
@@ -112,12 +107,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: quantity.value?.cartonCount ?? '未提供',
贴标件数: quantity.value?.labelPieceCount ?? '未提供/不适用',
SKU种数: quantity.value?.skuCount ?? '未提供',
当前关联托盘: currentPallets.value.length ? `${currentPallets.value.length} 托:${currentPallets.value.join('、')}${sourceMissing.value ? '(部分来源未提供)' : ''}` : '未提供',
本轮目标托盘: targetPallets.value.length ? `${targetPallets.value.length} 托:${targetPallets.value.join('、')}` : '未提供/不适用',
原托盘快照: '未提供(当前关联托盘不代表换标前快照)',
箱数: Number(value?.application.totalPackageCount || 0),
预算总价: '—',
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
@@ -129,24 +119,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
async function load() {
if (!orderId.value) { loadError.value = { message: '详情缺少换标单号,请从换标单列表重新进入' }; return }
loading.value = true; loadError.value = null
try {
detail.value = await getRelabelOrder(orderId.value)
quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; targetPallets.value = []; sourceMissing.value = false
try {
const result = await getRelabelQuantities({ page: 1, size: 1, relabelNo: orderId.value })
quantity.value = result.orders[orderId.value] || null
if (quantity.value?.duplicateCarton) quantityMessage.value = '该单存在重复箱唛关联,箱数已按箱唛去重,贴标件数待核查。'
const task = await getRelabelReviewTask(orderId.value)
currentPallets.value = [...new Set((task.items || []).map(item => item.sourcePalletCode).filter((value): value is string => Boolean(value)))]
sourceMissing.value = (task.items || []).some(item => !item.sourcePalletCode)
const history = task.history || []
const lastRework = history.map(item => item.action).lastIndexOf('REWORK_ARCHIVE')
const targets = history.slice(lastRework + 1).filter(item => item.action === 'PALLET_BIND').flatMap(item => {
try { const parsed: { targetPallets?: string[] } = JSON.parse(item.detail || '{}'); return parsed.targetPallets || [] } catch { return [] }
})
targetPallets.value = [...new Set(targets)]
} catch { quantityMessage.value = '部分数量或托盘资料加载失败,未将缺失值显示为0。' }
}
try { detail.value = await getRelabelOrder(orderId.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
finally { loading.value = false }
}
@@ -193,7 +166,6 @@ async function previewCompletionPhoto(fileId: string) {
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>
<RelabelDocumentsPanel v-if="detail" :relabel-no="detail.application.relabelNo" :detail="detail" /><section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label v-if="access.can(permission.relabelOrderCreate)" class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<p v-if="quantityMessage" role="status">{{ quantityMessage }}</p>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
<div class="relabel-mark-list__scroll">
@@ -8,7 +8,6 @@ import {
createRelabelOrder,
getRelabelOrder,
listRelabelOrders,
getRelabelQuantities,
listRelabelWarehouseOptions,
} from '../api/relabelOrders'
import RelabelOrdersView from './RelabelOrdersView.vue'
@@ -22,7 +21,6 @@ vi.mock('../api/relabelOrders', () => ({
createRelabelOrder: vi.fn(),
getRelabelOrder: vi.fn(),
listRelabelOrders: vi.fn(),
getRelabelQuantities: vi.fn(),
listRelabelWarehouseOptions: vi.fn(),
}))
@@ -44,26 +42,7 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('RelabelOrdersView', () => {
it('uses full-filter totals instead of the visible page and preserves unknown boxes', async () => {
vi.mocked(listRelabelOrders).mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-PIECE', skuCodes: 'A', totalPackageCount: 100, status: 'PROCESSING' }], page: 1, size: 10, total: 45, pages: 5 })
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 45, skuCount: 12, knownLabelPieceCount: 300, unknownPieceOrders: 1, knownCartonCount: 8, unknownCartonOrders: 2, orders: { 'RL-PIECE': { labelPieceCount: 100, cartonCount: null, skuCount: 1, duplicateCarton: false, quantityUnit: 'PIECE', sourcePalletCount: null, targetPalletCount: null } } })
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const summary = wrapper.get('[aria-label="全部筛选结果数量汇总"]')
expect(summary.text()).toContain('45 票')
expect(summary.text()).toContain('300 件')
expect(summary.text()).toContain('8 箱')
expect(wrapper.findAll('tbody tr').some(row => row.text().includes('RL-PIECE') && row.text().includes('未提供') && row.text().includes('100'))).toBe(true)
})
it('does not display all-unknown carton quantities as zero boxes', async () => {
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 1, knownLabelPieceCount: 100, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 1, orders: {} })
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).toContain('箱数未提供(1票)')
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).not.toContain('0 箱')
})
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 2, knownLabelPieceCount: 15, unknownPieceOrders: 0, knownCartonCount: 1, unknownCartonOrders: 0, orders: { 'RL-MULTI': { labelPieceCount: 15, cartonCount: 1, skuCount: 2, duplicateCarton: false, quantityUnit: 'CARTON', sourcePalletCount: null, targetPalletCount: null } } })
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-MULTI', skuCodes: 'SKU-A,SKU-B', totalPackageCount: 1, status: 'PENDING_REVIEW' }], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-MULTI', totalPackageCount: 1 }, items: [{ id: '1', skuCode: 'SKU-A', newSkuCode: 'NEW-A', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
@@ -85,7 +64,6 @@ describe('RelabelOrdersView', () => {
expect(wrapper.text()).toContain('贴标要求111')
})
beforeEach(() => {
vi.mocked(getRelabelQuantities).mockReset().mockResolvedValue({ orderCount: 0, skuCount: 0, knownLabelPieceCount: 0, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 0, orders: {} })
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
@@ -15,15 +15,14 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, getRelabelQuantities, type RelabelQuantityTotals, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation'
import type { RelabelOrderListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
type RelabelRow = Omit<RelabelOrderListItem, 'cartons'> & { cartons: string; labelPieces: string; skuCount: string }
const rows = ref<RelabelRow[]>([])
const rows = ref<RelabelOrderListItem[]>([])
const loading = ref(false)
const voiding = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
@@ -34,15 +33,11 @@ const warehousesLoading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const quantities = ref<RelabelQuantityTotals | null>(null)
const quantityError = ref('')
function dimension(value: number | string | null | undefined) { return value == null ? '未提供' : String(value) }
function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '待客户确认', value: 'CUSTOMER_CONFIRM_PENDING' }, { label: '已驳回', value: 'REJECTED' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
@@ -51,8 +46,6 @@ const columns: DataTableColumn[] = [
{ key: 'sku', label: '商品SKU', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'labelPieces', label: '贴标件数', width: 110, align: 'right' },
{ key: 'skuCount', label: 'SKU种数', width: 100, align: 'right' },
{ key: 'budgetTotal', label: '预算总价', width: 120, align: 'right' },
{ key: 'relabelCost', label: '换标费用', width: 120, align: 'right' },
{ key: 'walletStatus', label: '资金状态', width: 120 },
@@ -66,12 +59,11 @@ const dialogMode = ref<'create' | 'edit'>('create')
const submitting = ref(false)
const detailLoading = ref(false)
const editSourceStatus = ref('')
const auditLabelPieces = ref('未提供')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createIdempotencyKey = ref('')
const dialogError = ref<ApiErrorDetails | null>(null), customersError = ref<ApiErrorDetails | null>(null), warehousesError = ref<ApiErrorDetails | null>(null)
const formLocked = computed(() => submitting.value || detailLoading.value || dialogMode.value === 'create' && (customersLoading.value || warehousesLoading.value) || !access.can(dialogMode.value === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus))
const selectedRows = ref<RelabelRow[]>([])
const selectedRows = ref<RelabelOrderListItem[]>([])
const allowedStatusOptions = computed(() => {
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
@@ -87,10 +79,9 @@ function openPackingPlan() {
void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } })
}
async function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelRow | undefined
const row = source as RelabelOrderListItem | undefined
if (dialogOpen.value || submitting.value || detailLoading.value || !access.can(mode === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus) || mode === 'edit' && (!row || !canAudit(row))) return
dialogError.value = null; customersError.value = null; warehousesError.value = null
dialogMode.value = mode
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
editSourceStatus.value = ''
@@ -114,14 +105,13 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
relatedOrderNo: String(detail.application?.lastlegOrderNo || detail.application?.customerOrderNo || row.relatedOrderNo || ''),
sku: [...new Set(detail.items.flatMap(relabelSkuCodes))].join('、') || row.sku || '',
productName: [...new Set(detail.items.map(line => line.productNameZh?.trim()).filter(Boolean))].join('、') || row.productName || '',
cartons: optionalNumber(quantities.value?.orders[String(row.orderNo)]?.cartonCount),
cartons: optionalNumber(detail.application?.totalPackageCount ?? row.cartons),
labelRequirement: [...new Set(detail.items.map(line => line.labelRequirement?.trim()).filter(Boolean))].join(';') || row.labelRequirement || '',
shipmentNo: [...new Set(detail.items.map(line => line.shipmentNo?.trim()).filter(Boolean))].join('、'),
newSkuCode: detail.items.flatMap(line => line.skuItems?.length ? line.skuItems.map(sku => `${sku.newSkuCode} × ${sku.quantity} 件`) : [line.newSkuCode || '']).filter(Boolean).join(';'),
relabelCost: optionalNumber(detail.application?.chargeAmount ?? row.relabelCost),
remark: String(detail.remark || detail.application?.reviewRemark || row.remark || ''),
})
auditLabelPieces.value = dimension(quantities.value?.orders[String(row.orderNo)]?.labelPieceCount)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '换标单详情加载失败,请重试').message)
dialogOpen.value = false
@@ -189,23 +179,21 @@ async function submitForm() {
}
function openDetail(source: object) {
const row = source as RelabelRow
const row = source as RelabelOrderListItem
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
}
function relabelStatus(value: unknown) {
if (value === 'REJECTED') return '已驳回'
if (value === 'PENDING_REVIEW') return '待审核'
if (value === 'CANCELLED') return '已作废'
if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认'
return relabelBusinessStatusLabel(value)
}
function canVoid(source: object) {
const row = source as RelabelRow
const row = source as RelabelOrderListItem
return !['READY_FOR_OUTBOUND', 'COMPLETED', 'CANCELLED'].includes(row.rawStatus || relabelStatusCode(row.status))
}
async function voidOrder(source: object) {
const row = source as RelabelRow
const row = source as RelabelOrderListItem
if (voiding.value || !canVoid(row) || !access.can(permission.relabelOrderStatus)) return
voiding.value = String(row.orderNo)
try {
@@ -231,32 +219,24 @@ function relabelStatusCode(value: unknown) {
}
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
function canAudit(source: object) {
const row = source as RelabelRow
const row = source as RelabelOrderListItem
return row.reviewChannel !== 'TAIL_ORDER'
&& (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
}
let loadGeneration = 0
async function load(model: QueryModel = filters.value) {
const current = ++loadGeneration
Object.assign(filters.value, model)
loading.value = true; loadError.value = null
try {
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
quantities.value = null; quantityError.value = ''
const result = await listRelabelOrders(query)
const measured = await getRelabelQuantities(query).catch(() => null)
if (current !== loadGeneration) return
quantities.value = measured
if (!measured) quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计'
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
reviewChannel: item.reviewChannel || undefined,
createdAt: formatOverseasDateTime(item.createdAt),
}))
total.value = result.total
} catch (error) { if (current === loadGeneration) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') } }
finally { if (current === loadGeneration) loading.value = false }
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') }
finally { loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
@@ -269,7 +249,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
<section class="quantity-summary" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘/目标托盘数:未提供</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>换标单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
@@ -297,7 +276,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<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 colspan="3">{{ form.relatedOrderNo || '—' }}</td></tr><tr><th scope="row">商品SKU</th><td colspan="3">{{ form.sku || '—' }}</td></tr><tr><th scope="row">中文品名</th><td colspan="3">{{ form.productName || '—' }}</td></tr>
<tr><th scope="row">箱数</th><td>{{ form.cartons ?? '—' }}</td><th scope="row">换标费用(CNY)</th><td>{{ form.relabelCost ?? '—' }}</td></tr>
<tr><th scope="row">贴标件数</th><td colspan="3">{{ auditLabelPieces }}</td></tr>
<tr><th scope="row">贴标要求</th><td colspan="3">{{ form.labelRequirement || '—' }}</td></tr><tr><th scope="row">货件号</th><td colspan="3">{{ form.shipmentNo || '—' }}</td></tr><tr><th scope="row">新SKU</th><td colspan="3">{{ form.newSkuCode || '—' }}</td></tr>
</tbody></table></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 relabel-editor-table"><tbody><tr><td colspan="2"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></td></tr></tbody></table></section>
@@ -310,20 +288,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</template>
<style scoped>
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-bg-card, white); margin-bottom: 12px; font-size: 13px; }
h3 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
h3:not(:first-child) { margin-top: var(--admin-space-1); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
.packing-plan-dialog { display: grid; gap: 14px; }
.packing-plan-summary { display: flex; gap: 24px; color: var(--cc-text-secondary); font-size: var(--admin-font-body); }
.packing-mode { margin-bottom: 2px; }
.packing-box { border: 1px solid var(--cc-border); border-radius: 6px; padding: 14px; display: grid; gap: 10px; }
.packing-box-head, .packing-item, .packing-dimensions { display: flex; align-items: center; gap: 10px; }
.packing-box-head { justify-content: space-between; }
.packing-item .el-input { flex: 1; }
.packing-item .el-input-number { width: 160px; }
.packing-dimensions .el-input-number { width: 150px; }
.relabel-editor-table { table-layout: fixed; }
.relabel-editor-table td:not([colspan]) { width: 50%; }
@media (max-width: 720px) { .relabel-editor-table, .relabel-editor-table tbody { display: block; }.relabel-editor-table tr { display: grid; grid-template-columns: 1fr; }.relabel-editor-table td:not([colspan]) { width: auto; } }
@@ -57,24 +57,6 @@ afterEach(() => {
})
describe('ReturnInventoryManagementView shared list', () => {
it('shows discard deductions in stock movements and the physical carton history', async () => {
vi.mocked(listReturnInventoryMovements).mockResolvedValue({ records: [{ ...stock, movementType: 'DISCARD', referenceNo: 'CD-TEST-1', totalDelta: -5, availableDelta: -5, occurredAt: '2026-10-19T17:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
const inventory = await mountView('inventory')
const tabs = inventory.getComponent({ name: 'ElTabs' })
tabs.vm.$emit('update:modelValue', 'movements'); tabs.vm.$emit('tab-change', 'movements')
await flushPromises()
expect(inventory.text()).toContain('货物废弃')
expect(inventory.text()).toContain('CD-TEST-1')
expect(inventory.text()).toContain('-5')
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, totalQuantity: 5, items: [{ skuCode: 'SKU-TEST', quantity: 5 }], discardHistory: [{ discardNo: 'CD-TEST-1', skuCode: 'SKU-TEST', beforeQuantity: 10, quantity: 5, afterQuantity: 5, occurredAt: '2026-10-19T17:00:00' }] })
const cartons = await mountView('cartons')
await cartons.findAll('button').find(button => button.text() === '详情')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('废弃扣减明细')
expect(document.body.textContent).toContain('扣减前(件)')
expect(document.body.textContent).toContain('扣减后(件)')
expect(document.body.textContent).toContain('CD-TEST-1')
})
it('退货库存展示服务端换标占用列', async () => {
const wrapper = await mountView('inventory')
const columns = wrapper.findComponent(DataTable).props('columns')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
import "@/styles/adminDensity.css";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import dayjs from "dayjs";
import { useRoute } from "vue-router";
import { Search, Plus } from "@lucide/vue";
@@ -16,6 +16,12 @@ import DataTable, {
} from "@/shared/components/DataTable.vue";
import AdminPage from "@/shared/components/AdminPage.vue";
import AppDialog from "@/shared/components/AppDialog.vue";
import ApiErrorMeta from "@/shared/components/ApiErrorMeta.vue";
import LoadErrorState from "@/shared/components/LoadErrorState.vue";
import LineClampText from "@/shared/components/LineClampText.vue";
import { getApiErrorDetails, type ApiErrorDetails } from "@/shared/api/http";
import { useAccessPreviewStore } from "@/core/access/accessPreview";
import { overseasWarehousePermissions as permission } from "../permissions";
import {
getReturnCarton,
getReturnCartonLabel,
@@ -38,6 +44,25 @@ import {
} from "../api/relabelOrders";
const props = defineProps<{ mode: "inventory" | "cartons" }>();
const route = useRoute();
const access = useAccessPreviewStore();
const canManagePacking = computed(() => access.can(permission.returnInventoryList) && access.can("lastleg:inventory:manage"));
const canCreatePacking = computed(() => canManagePacking.value && access.can(permission.returnCartonsList));
const listError = ref<ApiErrorDetails | null>(null);
const customerError = ref<ApiErrorDetails | null>(null);
const warehouseError = ref<ApiErrorDetails | null>(null);
const actionError = ref<ApiErrorDetails | null>(null);
const packingSourceError = ref<ApiErrorDetails | null>(null);
const packingSubmitError = ref<ApiErrorDetails | null>(null);
const cartonError = ref<ApiErrorDetails | null>(null);
const cartonLoading = ref(false);
const selectedCartonMark = ref("");
const printingMark = ref("");
const planActionNo = ref("");
const packingRequestKey = ref("");
let listLoadSequence = 0;
let cartonLoadSequence = 0;
function newPackingRequestKey() { return globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`; }
const loading = ref(false),
total = ref(0),
detailOpen = ref(false);
@@ -115,15 +140,19 @@ function params() {
};
}
async function load() {
const sequence = ++listLoadSequence;
listError.value = null;
loading.value = true;
try {
if (props.mode === "inventory") {
if (inventoryTab.value === "stock") {
const page = await listReturnStock(params());
if (sequence !== listLoadSequence) return;
stockRows.value = page.records;
total.value = page.total;
} else if (inventoryTab.value === "movements") {
const page = await listReturnInventoryMovements(params());
if (sequence !== listLoadSequence) return;
movementRows.value = page.records;
total.value = page.total;
} else {
@@ -135,17 +164,24 @@ async function load() {
...(query.overseasWarehouseId ? { overseasWarehouseId: query.overseasWarehouseId } : {}),
...(query.skuCode.trim() ? { skuCode: query.skuCode.trim() } : {}),
});
if (sequence !== listLoadSequence) return;
planRows.value = page.records;
planTotal.value = page.total;
total.value = page.total;
}
} else {
const page = await listReturnCartons(params());
cartonRows.value = page.records;
if (sequence !== listLoadSequence) return;
cartonRows.value = page.records;
total.value = page.total;
}
} catch (error) {
if (sequence !== listLoadSequence) return;
stockRows.value = []; movementRows.value = []; planRows.value = []; cartonRows.value = [];
total.value = 0; planTotal.value = 0;
listError.value = getApiErrorDetails(error, "退货数据加载失败,请重试");
} finally {
loading.value = false;
if (sequence === listLoadSequence) loading.value = false;
}
}
function search() {
@@ -157,21 +193,25 @@ function changeInventoryTab() {
void load();
}
async function reviewPlan(plan: Record<string, unknown>) {
const no = String(plan.planNo || "");
if (!no || plan.status !== "PENDING_REVIEW" || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await reviewReturnPackingPlan(String(plan.planNo));
ElMessage.success("合箱计划审核通过,已推送 PDA");
await load();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "合箱计划审核失败",
);
}
await reviewReturnPackingPlan(no);
ElMessage.success("合箱计划审核通过,已推送 PDA"); await load();
} catch (error) { actionError.value = getApiErrorDetails(error, "合箱计划审核失败"); }
finally { planActionNo.value = ""; }
}
async function cancelPlan(plan: Record<string, unknown>) {
const no = String(plan.planNo || "");
if (!no || !["PENDING_REVIEW", "READY"].includes(String(plan.status)) || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning', customClass: 'admin-density-confirm' });
await cancelReturnPackingPlan(String(plan.planNo)); ElMessage.success('计划已取消,库存已释放'); await load();
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '取消失败'); }
await ElMessageBox.confirm(`确认取消合箱计划“${no}”?取消后释放本计划锁定的来源库存,是否继续?`, "取消合箱计划", { type: "warning", customClass: "admin-density-confirm" });
if (!canManagePacking.value) return;
await cancelReturnPackingPlan(no); ElMessage.success("计划已取消,库存已释放"); await load();
} catch (error) { if (error !== "cancel" && error !== "close") actionError.value = getApiErrorDetails(error, "取消合箱计划失败"); }
finally { planActionNo.value = ""; }
}
function openPlanDetail(row: Record<string, unknown>) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
@@ -231,44 +271,35 @@ async function loadCustomerOptions() {
}
}
async function loadOptions() {
optionsLoading.value = true;
if (optionsLoading.value) return;
optionsLoading.value = true; customerError.value = null; warehouseError.value = null;
try {
const [customerResult, warehouseResult] = await Promise.allSettled([
loadCustomerOptions(),
listSharedReceivingOverseasWarehouseOptions(),
]);
if (customerResult.status === "fulfilled")
customers.value = customerResult.value;
else customers.value = [];
if (warehouseResult.status === "fulfilled")
warehouses.value = warehouseResult.value;
else warehouses.value = [];
if (
customerResult.status === "rejected" ||
warehouseResult.status === "rejected"
) {
const failed = customerResult.status === "rejected" ? "客户" : "海外仓";
ElMessage.warning(`${failed}筛选数据加载失败,请检查服务或数据库迁移`);
}
} catch (error) {
ElMessage.warning(
error instanceof Error
? `筛选名称加载失败:${error.message}`
: "筛选名称加载失败",
);
} finally {
optionsLoading.value = false;
}
const [customerResult, warehouseResult] = await Promise.allSettled([loadCustomerOptions(), listSharedReceivingOverseasWarehouseOptions()]);
if (customerResult.status === "fulfilled") customers.value = customerResult.value;
else { customers.value = []; customerError.value = getApiErrorDetails(customerResult.reason, "客户候选加载失败"); }
if (warehouseResult.status === "fulfilled") warehouses.value = warehouseResult.value;
else { warehouses.value = []; warehouseError.value = getApiErrorDetails(warehouseResult.reason, "海外仓候选加载失败"); }
} finally { optionsLoading.value = false; }
}
async function openCarton(mark: string) {
if (!mark || !access.can(permission.returnCartonsList) || detailOpen.value || cartonLoading.value || packingSaving.value || planActionNo.value) return;
selectedCartonMark.value = mark; detail.value = undefined; cartonError.value = null; detailOpen.value = true;
await loadCarton();
}
async function loadCarton() {
const mark = selectedCartonMark.value;
if (!mark || !detailOpen.value || cartonLoading.value || !access.can(permission.returnCartonsList)) return;
const sequence = ++cartonLoadSequence;
cartonLoading.value = true; cartonError.value = null; detail.value = undefined;
try {
detail.value = await getReturnCarton(mark);
detailOpen.value = true;
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱号详情加载失败");
}
const result = await getReturnCarton(mark);
if (sequence === cartonLoadSequence && detailOpen.value) detail.value = result;
} catch (error) { if (sequence === cartonLoadSequence && detailOpen.value) cartonError.value = getApiErrorDetails(error, "箱号详情加载失败"); }
finally { if (sequence === cartonLoadSequence) cartonLoading.value = false; }
}
async function printLabel(mark: string) {
if (!access.can(permission.returnCartonsList) || printingMark.value) return;
printingMark.value = mark; actionError.value = null;
try {
const label = await getReturnCartonLabel(mark);
const popup = window.open("", "_blank");
@@ -280,10 +311,12 @@ async function printLabel(mark: string) {
popup.focus();
popup.print();
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱唛加载失败");
}
actionError.value = getApiErrorDetails(e, "箱唛加载失败");
} finally { printingMark.value = ""; }
}
function openPackingPlan() {
if (!canCreatePacking.value || packingSaving.value || packingDialogOpen.value || planActionNo.value) return;
packingRequestKey.value = newPackingRequestKey(); packingSubmitError.value = null;
packingForm.customerId = query.customerId;
packingForm.overseasWarehouseId = query.overseasWarehouseId || warehouses.value[0]?.id || "";
packingForm.returnNos = [];
@@ -299,6 +332,8 @@ const packingSources = ref<ReturnPackingSource[]>([]);
const packingSourceLoading = ref(false);
let packingLoadSequence = 0;
async function loadPackingSources() {
if (!canCreatePacking.value || packingSaving.value) return;
packingSourceError.value = null;
const sequence = ++packingLoadSequence;
packingSkuRows.value = []; packingBoxRows.value = []; packingSources.value = [];
packingReturnOptions.value = []; packingForm.returnNos = []; packingForm.returnNosInput = ""; packingForm.skuSearchInput = ""; packingForm.skuQuantities = {}; packingForm.sourceCartonMarks = [];
@@ -318,7 +353,7 @@ async function loadPackingSources() {
packingSources.value = sources;
updatePackingScope();
} catch (error) {
if (sequence === packingLoadSequence) ElMessage.error(error instanceof Error ? error.message : '客户退货库存加载失败,请重试');
if (sequence === packingLoadSequence) packingSourceError.value = getApiErrorDetails(error, "客户退货库存加载失败,请重试");
} finally { if (sequence === packingLoadSequence) packingSourceLoading.value = false; }
}
function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); }
@@ -351,6 +386,9 @@ function changePackingMode() {
updatePackingScope();
}
async function submitPackingPlan() {
if (!packingDialogOpen.value || packingSaving.value || optionsLoading.value || packingSourceLoading.value || customerError.value || warehouseError.value || packingSourceError.value || !canCreatePacking.value) return;
const invalidQuantity = packingSkuRows.value.some(row => packingForm.skuQuantities[row.key] !== undefined && (!Number.isInteger(packingForm.skuQuantities[row.key]) || packingForm.skuQuantities[row.key]! <= 0 || packingForm.skuQuantities[row.key]! > row.availableQuantity));
if (packingForm.mode === "SKU" && invalidQuantity) { ElMessage.warning("合箱数量必须为不超过可用量的正整数"); return; }
const sourceDeductions = packingSkuRows.value.filter(row => Number(packingForm.skuQuantities[row.key]) > 0)
.map(row => ({ cartonMark: row.cartonMark, skuCode: row.skuCode, quantity: Number(packingForm.skuQuantities[row.key]) }));
const selectedMarks = packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [...new Set(sourceDeductions.map(row => row.cartonMark))];
@@ -374,7 +412,7 @@ async function submitPackingPlan() {
);
return;
}
packingSaving.value = true;
packingSaving.value = true; packingSubmitError.value = null;
try {
await createReturnPackingPlan({
returnNos,
@@ -383,15 +421,12 @@ async function submitPackingPlan() {
sourceCartonMarks: selectedMarks,
sourceDeductions: packingForm.mode === 'SKU' ? sourceDeductions : undefined,
remark: packingForm.remark.trim() || undefined,
idempotencyKey:
globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`,
idempotencyKey: packingRequestKey.value,
});
ElMessage.success("合箱计划已创建,待审核后由 PDA 填写箱规并作业");
packingDialogOpen.value = false; inventoryTab.value = "plans"; query.page = 1; await load();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "合箱计划创建失败",
);
packingSubmitError.value = getApiErrorDetails(error, "合箱计划创建失败");
} finally {
packingSaving.value = false;
}
@@ -406,7 +441,8 @@ watch(() => [route.name, route.query.packing, route.query.customerId, route.quer
query.customerId = value('customerId');
query.overseasWarehouseId = value('overseasWarehouseId');
void load();
if (value('packing') === '1') {
if (value('packing') === '1' && canCreatePacking.value) {
packingRequestKey.value = newPackingRequestKey(); packingSubmitError.value = null;
packingForm.customerId = query.customerId;
packingForm.overseasWarehouseId = query.overseasWarehouseId;
packingForm.mode = 'BOX';
@@ -415,6 +451,15 @@ watch(() => [route.name, route.query.packing, route.query.customerId, route.quer
packingForm.returnNosInput = value('returnNos');
}
}, { immediate: true });
watch(packingDialogOpen, open => {
if (open) return;
packingLoadSequence++; packingSourceLoading.value = false; packingRequestKey.value = "";
});
watch(detailOpen, open => {
if (open) return;
cartonLoadSequence++; cartonLoading.value = false; selectedCartonMark.value = ""; detail.value = undefined; cartonError.value = null;
});
onBeforeUnmount(() => { listLoadSequence++; cartonLoadSequence++; packingLoadSequence++; });
onMounted(() => { void loadOptions(); });
const stockColumns: DataTableColumn[] = [
{ key: "stock-0", label: "客户", minWidth: 180, slotName: "stock-0" },
@@ -557,7 +602,6 @@ const tableColumns = computed(() =>
: planColumns,
);
</script>
<template>
<AdminPage as="div" class="business-page return-inventory-page admin-density">
<DataTable
@@ -569,6 +613,7 @@ const tableColumns = computed(() =>
:data="tableRows"
:columns="tableColumns"
:loading="loading"
:results-visible="!listError"
:total="total"
:page-sizes="[20, 50, 100]"
:preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`"
@@ -585,12 +630,19 @@ const tableColumns = computed(() =>
>
<template #toolbar-actions
><el-button
v-if="mode === 'inventory'"
v-if="mode === 'inventory' && canCreatePacking"
:disabled="packingSaving || Boolean(planActionNo)"
type="warning"
@click="openPackingPlan"
><Plus :size="15" />创建合箱计划</el-button
></template
>
<template #notices>
<LoadErrorState v-if="listError" title="退货数据加载失败" :message="listError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="退货操作失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #filters
><el-tabs
class="density-element-tabs density-status-tabs"
@@ -679,7 +731,7 @@ const tableColumns = computed(() =>
warehouseName(row.overseasWarehouseId)
}}</template>
<template #movement-4="{ row }">{{
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库", DISCARD: "货物废弃",
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库",
SKU_CONVERSION_OUT: "SKU换标", SKU_CONVERSION_IN: "SKU换标",
SKU_CONVERSION_REVERSE_IN: "SKU换标撤销", SKU_CONVERSION_REVERSE_OUT: "SKU换标撤销"
} as Record<string, string>)[row.movementType] || row.movementType || "—"
@@ -713,22 +765,25 @@ const tableColumns = computed(() =>
@click="openPlanDetail(row)"
>详情</el-button
><el-button
v-if="row.status === 'PENDING_REVIEW'"
v-if="canManagePacking && row.status === 'PENDING_REVIEW'"
class="density-action"
data-tone="warning"
:disabled="Boolean(planActionNo) || packingSaving"
@click="reviewPlan(row)"
>审核</el-button><el-button v-if="['PENDING_REVIEW','READY'].includes(row.status)" class="density-action" data-tone="danger" @click="cancelPlan(row)">取消</el-button>
>审核</el-button><el-button v-if="canManagePacking && ['PENDING_REVIEW','READY'].includes(row.status)" :disabled="Boolean(planActionNo) || packingSaving" class="density-action" data-tone="danger" @click="cancelPlan(row)">取消</el-button>
</div></template
><template #carton-0="{ row }"
><div class="row-actions density-actions admin-text-actions">
<el-button
class="density-action"
data-tone="neutral"
v-if="access.can(permission.returnCartonsList)" :disabled="cartonLoading"
@click="openCarton(row.cartonMark)"
>详情</el-button
><el-button
class="density-action"
data-tone="neutral"
v-if="access.can(permission.returnCartonsList)" :loading="printingMark === row.cartonMark" :disabled="Boolean(printingMark)"
@click="printLabel(row.cartonMark)"
>箱唛</el-button
>
@@ -748,240 +803,145 @@ const tableColumns = computed(() =>
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
}}</template>
</DataTable>
<AppDialog
class="packing-dialog admin-density-dialog"
v-model="packingDialogOpen"
title="创建退货合箱计划"
width="min(1380px, calc(100vw - 32px))"
confirm-text="提交审核"
:confirm-loading="packingSaving"
@confirm="submitPackingPlan"
><el-form v-loading="packingSourceLoading" label-position="top" class="packing-form"
><el-form-item label="客户"
><el-select
v-model="packingForm.customerId"
filterable
placeholder="请选择客户"
style="width: 100%"
@change="loadPackingSources"
><el-option
v-for="item in customerOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式"
><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"
><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button
><el-radio-button label="SKU"
>部分包裹/箱合箱</el-radio-button
></el-radio-group
></el-form-item
><el-form-item label="来源退货单筛选(可选,后台自动追溯)"
><el-input
v-model="packingForm.returnNosInput"
clearable
placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"
style="width: 100%"
><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item
><el-form-item v-if="packingForm.mode === 'SKU'" label="来源包裹/箱和合箱数量"
><div class="packing-sku-list-wrap">
<el-input
v-model="packingForm.skuSearchInput"
clearable
placeholder="请输入 SKU,多个 SKU 请用英文逗号分隔"
class="packing-sku-search"
><template #suffix><span v-if="packingForm.skuSearchInput.trim()" class="return-nos-count">按 SKU 筛选</span></template></el-input>
<div class="packing-sku-list">
<div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div>
<div
v-for="item in visiblePackingSkuRows"
:key="item.key"
class="packing-sku-row"
>
<el-checkbox
:model-value="
packingForm.skuQuantities[item.key] !== undefined
"
@change="togglePackingSku(item.key, Boolean($event))"
>{{ item.cartonMark }} · {{ item.skuCode }}</el-checkbox
><span>可用 {{ item.availableQuantity }}</span
><el-input-number
v-if="packingForm.skuQuantities[item.key] !== undefined"
v-model="packingForm.skuQuantities[item.key]"
:min="1"
:max="item.availableQuantity"
:controls="false"
placeholder="数量"
/>
</div>
<el-empty
v-if="!visiblePackingSkuRows.length"
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'"
/></div></div></el-form-item
><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细"
><div class="packing-sku-list-wrap">
<el-input v-model="packingForm.skuSearchInput" clearable placeholder="按箱内 SKU 查找,多个 SKU 请用英文逗号分隔" class="packing-sku-search" />
<div class="packing-box-list">
<div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div>
<div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group>
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
</div>
<el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" />
</div></div></el-form-item
><el-alert
type="info"
:closable="false"
title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" /><el-form-item
label="合箱备注(可选)"
><el-input
v-model="packingForm.remark"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
resize="vertical"
placeholder="请输入合箱说明,最多 1000 字" /></el-form-item></el-form
></AppDialog>
<AppDialog
class="admin-density-dialog"
v-model="planDetailOpen"
title="合箱计划详情"
width="min(1400px, calc(100vw - 56px))"
:show-footer="false"
><template v-if="planDetail"
><el-descriptions :column="2" border
><el-descriptions-item label="计划号">{{
planDetail.planNo
}}</el-descriptions-item
><el-descriptions-item label="客户">{{
customerName(planDetail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="合箱方式">{{
planDetail.mode === "SKU" ? "部分包裹/箱合箱" : "整包裹/箱合箱"
}}</el-descriptions-item
><el-descriptions-item label="状态">{{
planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核'
}}</el-descriptions-item
><el-descriptions-item label="来源退货单" :span="2">{{
planDetail.returnNos?.join("、") || "—"
}}</el-descriptions-item
><el-descriptions-item label="备注" :span="2">{{
planDetail.remark || "—"
}}</el-descriptions-item></el-descriptions
>
<h3>计划来源箱明细</h3>
<el-table :data="planSourceRows" class="detail-items" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210" />
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
<template v-if="planDetail.status === 'COMPLETED'">
<h3>实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" class="detail-items" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<AppDialog class="admin-density-dialog" v-model="packingDialogOpen" title="创建退货合箱计划" width="min(1380px, calc(100vw - 32px))" fullscreenable confirm-text="提交审核" :confirm-loading="packingSaving" :confirm-disabled="optionsLoading || packingSourceLoading || Boolean(customerError || warehouseError || packingSourceError) || !canCreatePacking" @confirm="submitPackingPlan">
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="packingSubmitError" title="合箱计划创建失败" :message="packingSubmitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="packingSubmitError" /></template></LoadErrorState>
<el-form class="packing-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="packingSaving || !canCreatePacking">
<section class="density-dialog-section" data-tone="work" aria-label="合箱基础资料">
<table class="density-table density-table--editable packing-basic-table"><tbody>
<tr><td><el-form-item label="客户"><el-select v-model="packingForm.customerId" filterable :loading="optionsLoading" :disabled="optionsLoading || Boolean(customerError)" placeholder="请选择客户" @change="loadPackingSources"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td>
<td><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" :loading="optionsLoading" :disabled="optionsLoading || Boolean(warehouseError)" placeholder="请选择海外仓" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="合箱方式"><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button><el-radio-button label="SKU">部分包裹/箱合箱</el-radio-button></el-radio-group></el-form-item></td>
<td><el-form-item label="退货单筛选"><el-input v-model="packingForm.returnNosInput" clearable placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count density-dialog-note">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="合箱来源选择">
<h3 class="density-dialog-section__title">{{ packingForm.mode === 'SKU' ? '来源包裹/箱和合箱数量' : '箱维度明细' }}</h3>
<p v-if="packingSourceLoading" class="density-dialog-note" role="status">正在读取可用来源库存…</p>
<LoadErrorState v-if="packingSourceError" title="合箱来源加载失败" :message="packingSourceError.message" :loading="packingSourceLoading" @retry="loadPackingSources"><template #details><ApiErrorMeta :details="packingSourceError" /></template></LoadErrorState>
<el-input v-model="packingForm.skuSearchInput" clearable :placeholder="packingForm.mode === 'SKU' ? '请输入 SKU,多个 SKU 请用英文逗号分隔' : '按箱内 SKU 查找,多个 SKU 请用英文逗号分隔'" class="packing-sku-search"><template #suffix><span v-if="packingForm.skuSearchInput.trim()" class="return-nos-count density-dialog-note">按 SKU 筛选</span></template></el-input>
<table v-if="packingForm.mode === 'SKU'" class="density-table density-table--editable packing-source-table">
<colgroup><col /><col class="packing-available" /><col class="packing-quantity" /></colgroup>
<thead><tr><th>选择 / 箱唛 · SKU</th><th>可用数量</th><th>合箱数量</th></tr></thead><tbody>
<tr v-for="item in visiblePackingSkuRows" :key="item.key" class="packing-sku-row">
<td><el-checkbox :model-value="packingForm.skuQuantities[item.key] !== undefined" @change="togglePackingSku(item.key, Boolean($event))"><LineClampText :content="item.cartonMark + ' · ' + item.skuCode" /></el-checkbox></td>
<td>可用 {{ item.availableQuantity }}</td>
<td><el-input-number v-if="packingForm.skuQuantities[item.key] !== undefined" v-model="packingForm.skuQuantities[item.key]" :min="1" :max="item.availableQuantity" :precision="0" :controls="false" placeholder="数量" /></td>
</tr>
<tr v-if="!visiblePackingSkuRows.length && !packingSourceLoading && !packingSourceError"><td colspan="3"><el-empty :description="!packingForm.customerId || !packingForm.overseasWarehouseId ? '请选择客户和海外仓后读取可用来源' : packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'" /></td></tr>
</tbody>
</table>
<table v-else class="density-table packing-source-table">
<colgroup><col class="packing-selection" /><col class="packing-mark" /><col class="packing-return" /><col class="packing-unit" /><col /></colgroup>
<thead><tr><th>选择</th><th>箱唛</th><th>来源退货单</th><th>类型</th><th>SKU 明细</th></tr></thead><tbody>
<tr v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<td><el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="'选择箱唛 ' + box.cartonMark"><span /></el-checkbox></el-checkbox-group></td>
<td><LineClampText :content="box.cartonMark" /></td><td><LineClampText :content="box.returnNo" /></td>
<td>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</td>
<td><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span></td>
</tr>
<tr v-if="!visiblePackingBoxRows.length && !packingSourceLoading && !packingSourceError"><td colspan="5"><el-empty :description="!packingForm.customerId || !packingForm.overseasWarehouseId ? '请选择客户和海外仓后读取可用来源' : packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" /></td></tr>
</tbody>
</table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="合箱备注">
<el-alert type="info" :closable="false" title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" />
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="合箱备注(可选)"><el-input v-model="packingForm.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit resize="vertical" placeholder="请输入合箱说明,最多 1000 字" /></el-form-item></td></tr></tbody></table>
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="planDetailOpen" :title="'合箱计划详情 · ' + (planDetail?.planNo || '—')" width="min(1400px, calc(100vw - 56px))" fullscreenable :show-footer="false">
<div v-if="planDetail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="合箱计划资料">
<table class="density-table return-detail-facts"><tbody>
<tr><th scope="row">计划号</th><td><LineClampText :content="planDetail.planNo" /></td><th scope="row">客户</th><td><LineClampText :content="customerName(planDetail.customerId)" /></td></tr>
<tr><th scope="row">合箱方式</th><td>{{ planDetail.mode === 'SKU' ? '部分包裹/箱合箱' : '整包裹/箱合箱' }}</td><th scope="row">状态</th><td>{{ planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核' }}</td></tr>
<tr><th scope="row">来源退货单</th><td colspan="3"><LineClampText :content="planDetail.returnNos?.join('、')" :lines="2" /></td></tr>
<tr><th scope="row">备注</th><td colspan="3"><LineClampText :content="planDetail.remark" :lines="2" /></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="计划来源箱明细">
<h3 class="density-dialog-section__title">计划来源箱明细</h3>
<el-table :data="planSourceRows" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210"><template #default="{ row }"><LineClampText :content="row.sourceReturnNo" /></template></el-table-column>
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.cartonMark" /></template></el-table-column>
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
</section>
<section v-if="planDetail.status === 'COMPLETED'" class="density-dialog-section" data-tone="source" aria-label="实际合箱流向">
<h3 class="density-dialog-section__title">实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210"><template #default="{ row }"><LineClampText :content="row.returnNo" /></template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="180"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="本次合入" width="100" />
</el-table>
<h3>合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})</h3>
<p>结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
</template>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows" class="detail-items">
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
<el-table-column label="操作" width="130"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" :disabled="!planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" class="detail-items" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table></template
></AppDialog
><AppDialog
class="admin-density-dialog"
v-model="detailOpen"
title="退货箱号详情"
width="min(1080px, calc(100vw - 56px))"
:show-footer="false"
><template v-if="detail"
><el-descriptions class="density-descriptions" :column="2" border
><el-descriptions-item label="箱号">{{
detail.cartonMark
}}</el-descriptions-item
><el-descriptions-item label="客户">{{
customerName(detail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="海外仓">{{
warehouseName(detail.overseasWarehouseId)
}}</el-descriptions-item
><el-descriptions-item label="来源退货单">{{
detail.sourceReturnNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="FBA箱唛">{{
detail.fbaCartonMark || "—"
}}</el-descriptions-item
><el-descriptions-item label="目的仓">{{
detail.destinationWarehouseCode ||
detail.destinationWarehouseName ||
"—"
}}</el-descriptions-item
><el-descriptions-item label="换标单号">{{
detail.relabelNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="换标状态">{{
returnRelabelStatusLabel(detail.relabelStatus)
}}|实际完成 {{ detail.relabelledQuantity ?? "—" }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? "—" }} 件</el-descriptions-item
><el-descriptions-item label="状态">{{
statusLabel(detail.status)
}}</el-descriptions-item
><el-descriptions-item label="箱规">{{
detail.length
? `${detail.length}×${detail.width}×${detail.height} cm`
: "—"
}}</el-descriptions-item
><el-descriptions-item label="重量">{{
detail.weight ? `${detail.weight} kg` : "—"
}}</el-descriptions-item></el-descriptions
><el-table :data="detail.items" class="detail-items"
><el-table-column prop="skuCode" label="SKU" /><el-table-column
prop="productNameZh"
label="中文品名" /><el-table-column
prop="quantity"
label="数量"
align="right" /></el-table>
<h3>废弃扣减明细</h3>
<el-table :data="detail.discardHistory || []" class="detail-items" empty-text="暂无废弃记录">
<el-table-column prop="occurredAt" label="操作时间" min-width="170" />
<el-table-column prop="discardNo" label="废弃单号" min-width="210" />
<el-table-column prop="skuCode" label="SKU" min-width="170" />
<el-table-column prop="productNameZh" label="中文品名" min-width="140" />
<el-table-column prop="beforeQuantity" label="扣减前(件)" width="110" align="right" />
<el-table-column prop="quantity" label="废弃数量(件)" width="120" align="right" />
<el-table-column prop="afterQuantity" label="扣减后(件)" width="110" align="right" />
</el-table>
<h3>合箱明细</h3>
<el-table :data="detail.mergeHistory || []" class="detail-items" empty-text="暂无合箱记录">
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '现场合箱 / 历史未关联' }}</template></el-table-column>
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="returnNo" label="来源退货单" min-width="240" />
<el-table-column prop="skuCode" label="SKU" min-width="170" />
<el-table-column prop="quantity" label="本次合箱数量" width="110" align="right" />
</el-table>
</template
></AppDialog>
</section>
<section class="density-dialog-section" data-tone="summary" aria-label="合箱结果">
<h3 class="density-dialog-section__title">{{ planDetail.status === 'COMPLETED' ? '合箱完成时结果(' + (planDetail.completedBy || '操作员未留存') + ')' : '计划箱子明细' }}</h3>
<p v-if="planDetail.status === 'COMPLETED'" class="density-dialog-note">结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows">
<el-table-column v-if="access.can(permission.returnCartonsList)" label="操作" width="130"><template #default="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :loading="printingMark === row.cartonMark" :disabled="Boolean(printingMark) || !planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></div></template></el-table-column>
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250"><template #default="{ row }"><LineClampText :content="row.cartonMark" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="300"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
<el-table-column prop="skuCode" label="SKU" min-width="300"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="'退货箱号详情 · ' + (selectedCartonMark || '—')" width="min(1080px, calc(100vw - 56px))" fullscreenable :show-footer="false" :loading="cartonLoading" :confirm-loading="cartonLoading">
<p v-if="cartonLoading" class="density-dialog-note" role="status">正在读取退货箱号详情…</p>
<LoadErrorState v-if="cartonError" title="箱号详情加载失败" :message="cartonError.message" :loading="cartonLoading" @retry="loadCarton"><template #details><ApiErrorMeta :details="cartonError" /></template></LoadErrorState>
<div v-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="退货箱号资料">
<table class="density-table return-detail-facts"><tbody>
<tr><th scope="row">箱号</th><td><LineClampText :content="detail.cartonMark" /></td><th scope="row">状态</th><td>{{ statusLabel(detail.status) }}</td></tr>
<tr><th scope="row">客户</th><td><LineClampText :content="customerName(detail.customerId)" /></td><th scope="row">海外仓</th><td><LineClampText :content="warehouseName(detail.overseasWarehouseId)" /></td></tr>
<tr><th scope="row">来源退货单</th><td><LineClampText :content="detail.sourceReturnNo" /></td><th scope="row">FBA箱唛</th><td><LineClampText :content="detail.fbaCartonMark" /></td></tr>
<tr><th scope="row">目的仓</th><td><LineClampText :content="detail.destinationWarehouseCode || detail.destinationWarehouseName" /></td><th scope="row">换标单号</th><td><LineClampText :content="detail.relabelNo" /></td></tr>
<tr><th scope="row">换标状态</th><td colspan="3">{{ returnRelabelStatusLabel(detail.relabelStatus) }}|实际完成 {{ detail.relabelledQuantity ?? '—' }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? '—' }} 件</td></tr>
<tr><th scope="row">箱规</th><td>{{ detail.length ? detail.length + '×' + detail.width + '×' + detail.height + ' cm' : '—' }}</td><th scope="row">重量</th><td>{{ detail.weight ? detail.weight + ' kg' : '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="退货箱内SKU">
<h3 class="density-dialog-section__title">箱内SKU明细</h3>
<el-table :data="detail.items">
<el-table-column prop="skuCode" label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="productNameZh" label="中文品名"><template #default="{ row }"><LineClampText :content="row.productNameZh" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" align="right" />
</el-table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="退货合箱明细">
<h3 class="density-dialog-section__title">合箱明细</h3>
<el-table :data="detail.mergeHistory || []" empty-text="暂无合箱记录">
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }"><LineClampText :content="row.planNo" /></template></el-table-column>
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
<el-table-column prop="returnNo" label="来源退货单" min-width="240"><template #default="{ row }"><LineClampText :content="row.returnNo" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="170"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="本次合箱数量" width="110" align="right" />
</el-table>
</section>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
@@ -1006,135 +966,25 @@ const tableColumns = computed(() =>
justify-content: flex-end;
margin-top: var(--admin-space-5);
}
.detail-items {
margin-top: var(--admin-space-5);
}
.packing-form {
padding: 2px 4px;
}
.packing-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.packing-mode {
display: flex;
width: 100%;
}
.packing-mode :deep(.el-radio-button) {
flex: 1;
}
.packing-mode :deep(.el-radio-button__inner) {
width: 100%;
padding: 9px 14px;
}
.packing-sku-list-wrap { width: 100%; }
.packing-sku-search { margin-bottom: 8px; }
.packing-sku-list {
display: grid;
gap: 0;
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
}
.packing-sku-list-head,
.packing-sku-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 92px;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 7px 10px;
border-bottom: 1px solid var(--cc-border);
}
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.packing-sku-row:last-of-type { border-bottom: 0; }
.packing-sku-row > span {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
white-space: nowrap;
}
.packing-sku-row :deep(.el-input-number) {
width: 68px;
}
.packing-sku-row :deep(.el-input-number .el-input__wrapper) {
padding: 0 7px;
}
.packing-sku-row :deep(.el-input__inner) {
font-size: var(--admin-font-caption);
text-align: center;
}
.packing-box-list {
display: grid;
gap: 0;
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
}
.packing-box-list-head,
.packing-box-list-row {
display: grid;
grid-template-columns: 72px minmax(180px, 1.15fr) minmax(150px, 1fr) 90px minmax(220px, 2fr);
align-items: center;
gap: 8px;
padding: 8px 10px;
}
.packing-box-list-row > strong,
.packing-box-list-row > span { overflow-wrap: anywhere; }
.packing-box-list-head {
position: sticky;
top: 0;
z-index: 1;
color: var(--cc-muted);
font-size: var(--admin-font-caption);
background: var(--cc-surface-subtle, #f8fafc);
border-bottom: 1px solid var(--cc-border);
}
.packing-box-list-row {
min-height: 42px;
border-bottom: 1px solid var(--cc-border);
}
.packing-box-list-row:last-of-type {
border-bottom: 0;
}
.packing-box-list-row > span,
.packing-box-items {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.packing-box-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.return-nos-count { color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
.packing-form :deep(.el-alert) {
margin-top: 2px;
padding: 8px 12px;
}
.packing-form :deep(.el-alert__title) {
font-size: var(--admin-font-caption);
}
@media (max-width: 700px) {
.packing-box-list { overflow-x: auto; }
.packing-box-list-head,
.packing-box-list-row { min-width: 760px; }
}
:deep(.packing-dialog.el-dialog) {
resize: both;
overflow: auto;
min-width: 720px;
min-height: 520px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
}
:deep(.packing-dialog .el-dialog__body) {
min-width: 0;
}
.packing-form :deep(.el-textarea__inner) {
width: 100%;
line-height: 1.6;
}
.packing-basic-table { table-layout: fixed; }
.packing-basic-table td { width: 50%; }
.packing-mode { display: flex; width: 100%; }
.packing-mode :deep(.el-radio-button) { flex: 1; }
.packing-mode :deep(.el-radio-button__inner) { width: 100%; }
.packing-source-table { table-layout: fixed; }
.packing-source-table th { position: sticky; top: 0; z-index: 1; }
.packing-available { width: 100px; }
.packing-quantity { width: 130px; }
.packing-selection { width: 56px; }
.packing-mark { width: 220px; }
.packing-return { width: 200px; }
.packing-unit { width: 96px; }
.packing-sku-search { margin-bottom: var(--admin-space-3); }
.packing-sku-row .el-checkbox { max-width: 100%; }
.packing-sku-row :deep(.el-checkbox__label) { min-width: 0; max-width: 100%; }
.packing-box-items { display: flex; flex-wrap: wrap; gap: 6px; }
.return-nos-count { white-space: nowrap; }
.return-detail-facts { table-layout: fixed; }
.return-detail-facts th { width: 110px; }
</style>
@@ -8,7 +8,6 @@ import {
getPlatformUnifiedOrder,
downloadPlatformProductImage,
listPlatformUnifiedOrders,
getPlatformUnifiedOrderStatusCounts,
listPlatformUnifiedOrderFeeDetails,
updatePlatformUnifiedOrderDeclaration,
updatePlatformUnifiedOrderPlanning,
@@ -27,14 +26,6 @@ describe('unified orders API contract', () => {
vi.mocked(http.put).mockReset()
})
it('requests unpaged counts without main status and preserves unknown counts as absent', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { counts: { PENDING_REVIEW: 8, WAIT_MEASURE: 0, LOADED: -1, LOADING: null, DELIVERED: '2' } } })
await expect(getPlatformUnifiedOrderStatusCounts({ inboundCode: 'IN-8', loaded: false, fullContainer: false, channelName: '', ...{ page: 2, size: 20, mainStatus: 'LOADING' } })).resolves.toEqual({ counts: { PENDING_REVIEW: 8, WAIT_MEASURE: 0 } })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/status-counts', {
params: { inboundCode: 'IN-8', loaded: false, fullContainer: false },
})
})
it('uploads the old-system workbook with a retry-safe key', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { total: 1, succeeded: 1, failed: 0, items: [] } })
const file = new File(['xlsx'], 'legacy.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
@@ -11,26 +11,16 @@ export type UnifiedOrderMainStatus =
| 'LOADED'
| 'DELIVERED'
| 'COMPLETED'
| 'WAIT_WEIGHT_CONFIRM' | 'WEIGHT_CONFIRMED' | 'WAIT_CUSTOMS_DOCS' | 'EXPORT_CUSTOMS'
| 'SAILING_CLEARANCE' | 'WAIT_UNLOAD' | 'WAIT_DISPATCH' | 'OUTBOUND_DOCUMENT' | 'DELIVERING' | 'SIGNED'
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_WEIGHT_CONFIRM', label: '待客户确认' },
{ value: 'WEIGHT_CONFIRMED', label: '已确认数据' },
{ value: 'WAIT_CUSTOMS_DOCS', label: '待上传资料' },
{ value: 'WAIT_PALLETIZE', label: '一件代发打托' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'EXPORT_CUSTOMS', label: '出口报关' },
{ value: 'SAILING_CLEARANCE', label: '已开船/待清关' },
{ value: 'WAIT_UNLOAD', label: '待拆柜' },
{ value: 'WAIT_DISPATCH', label: '待排车' },
{ value: 'OUTBOUND_DOCUMENT', label: '出库单' },
{ value: 'DELIVERING', label: '派送中' },
{ value: 'SIGNED', label: '签收完成' },
{ value: 'DELIVERED', label: '派送完成' },
{ value: 'COMPLETED', label: '已完成' },
]
export interface UnifiedOrderMeasurementSummary {
@@ -324,23 +314,6 @@ export interface UnifiedOrderQuery {
size: number
}
export type UnifiedOrderStatusCountQuery = Omit<UnifiedOrderQuery, 'page' | 'size' | 'mainStatus'>
export interface UnifiedOrderStatusCounts {
counts: Partial<Record<UnifiedOrderMainStatus | 'MEASUREMENT_REVIEW', number>>
}
export async function getPlatformUnifiedOrderStatusCounts(query: UnifiedOrderStatusCountQuery): Promise<UnifiedOrderStatusCounts> {
// Strip these fields at runtime too, so callers cannot accidentally scope the other tabs.
const params = Object.fromEntries(Object.entries(query).filter(([key, value]) =>
!['page', 'size', 'mainStatus'].includes(key) && value !== undefined && value !== ''))
const response = await http.get<ApiEnvelope<UnifiedOrderStatusCounts>, ApiEnvelope<UnifiedOrderStatusCounts>>(
`${unifiedOrdersApiPath}/status-counts`, { params },
)
const result = unwrapApiResponse(response)
return { counts: Object.fromEntries(Object.entries(result.counts || {}).filter(([, count]) =>
typeof count === 'number' && Number.isSafeInteger(count) && count >= 0)) }
}
type PagePayload = Omit<UnifiedOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
@@ -694,7 +667,5 @@ export async function listPlatformUnifiedOrderFeeDetails(accountId: string, inbo
}
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
if (status === 'DELIVERED') return '签收完成'
if (status === 'COMPLETED') return '已完成'
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || '未知状态'
}
@@ -14,7 +14,7 @@ vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundM
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }],
}))
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
@@ -28,7 +28,7 @@ vi.mock('@/shared/api/inboundCodeRelations', () => ({
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
downloadPlatformProductImage: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [],
}))
@@ -1,6 +1,6 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getPlatformUnifiedOrderStatusCounts, listPlatformUnifiedOrders, updatePlatformUnifiedOrderPlanning } from '@/modules/unified-orders/api/unifiedOrders'
import { listPlatformUnifiedOrders, updatePlatformUnifiedOrderPlanning } from '@/modules/unified-orders/api/unifiedOrders'
import { createPlatformExportTask } from '@/modules/downloads'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { ApiResponseError } from '@/shared/api/http'
@@ -31,7 +31,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
approvePlatformUnifiedOrder: vi.fn(),
getPlatformUnifiedOrder: vi.fn(),
listPlatformUnifiedOrderFeeDetails: vi.fn(),
getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(),
listPlatformUnifiedOrders: vi.fn(),
updatePlatformUnifiedOrderPlanning: vi.fn(),
unifiedOrderStatusOptions: [
{ value: 'PENDING_REVIEW', label: '待审核' },
@@ -39,8 +39,8 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERING', label: '派送中' },
{ value: 'SIGNED', label: '签收完成' },
{ value: 'DELIVERED', label: '派送完成' },
{ value: 'COMPLETED', label: '已完成' },
],
}))
@@ -69,70 +69,12 @@ function mountView() {
describe('UnifiedOrdersView list request coordination', () => {
beforeEach(() => {
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockReset().mockResolvedValue({ counts: {} })
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
vi.mocked(updatePlatformUnifiedOrderPlanning).mockReset()
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '国内仓订单', taskNo: 'EXP-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
it('shows only supported counts, keeps all text-only and never scopes counts by status or pagination', async () => {
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValue({ counts: { PENDING_REVIEW: 12, WAIT_MEASURE: 0, SIGNED: 3 } })
const wrapper = mountView()
await flushPromises()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs[0]!.text()).toBe('全部')
expect(tabs[1]!.text()).toBe('待审核(12)')
expect(tabs[2]!.text()).toBe('待测量数据(0)')
expect(tabs[3]!.text()).toBe('测量数据审核')
expect(tabs.at(-1)!.text()).toBe('签收完成(3)')
expect(tabs.map((tab) => tab.text())).not.toContain('派送完成')
expect(tabs.map((tab) => tab.text())).not.toContain('已完成')
await tabs[1]!.trigger('click')
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
await flushPromises()
expect(getPlatformUnifiedOrderStatusCounts).toHaveBeenCalledTimes(1)
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), inboundCode: ' IN-9 ', loaded: 'false' })
toolbar.vm.$emit('search')
await flushPromises()
expect(getPlatformUnifiedOrderStatusCounts).toHaveBeenCalledTimes(2)
const params = vi.mocked(getPlatformUnifiedOrderStatusCounts).mock.lastCall![0]
expect(params).toMatchObject({ inboundCode: 'IN-9', loaded: false })
expect(params).not.toHaveProperty('mainStatus')
expect(params).not.toHaveProperty('page')
expect(params).not.toHaveProperty('size')
toolbar.vm.$emit('reset')
await flushPromises()
expect(getPlatformUnifiedOrderStatusCounts).toHaveBeenCalledTimes(3)
})
it('ignores older count responses and hides stale counts during loading and failures with retry', async () => {
let resolveOld!: (value: { counts: { PENDING_REVIEW: number } }) => void
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve }))
const wrapper = mountView()
await flushPromises()
expect(wrapper.get('[role="tablist"]').attributes('aria-busy')).toBe('true')
expect(wrapper.find('.order-status-count').exists()).toBe(false)
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValueOnce({ counts: { PENDING_REVIEW: 7 } })
wrapper.getComponent({ name: 'QueryToolbar' }).vm.$emit('search')
await flushPromises()
resolveOld({ counts: { PENDING_REVIEW: 99 } })
await flushPromises()
expect(wrapper.findAll('[role="tab"]')[1]!.text()).toBe('待审核(7)')
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockRejectedValueOnce(new Error('统计不可用'))
wrapper.getComponent({ name: 'QueryToolbar' }).vm.$emit('search')
await flushPromises()
expect(wrapper.find('.order-status-count').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(true)
expect(wrapper.find('.order-status-feedback').text()).toContain('重试数量')
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValueOnce({ counts: { PENDING_REVIEW: 8, MEASUREMENT_REVIEW: 3 } })
await wrapper.findAll('button').find((button) => button.text() === '重试数量')!.trigger('click')
await flushPromises()
expect(wrapper.findAll('[role="tab"]')[1]!.text()).toBe('待审核(8)')
expect(wrapper.findAll('[role="tab"]')[3]!.text()).toBe('测量数据审核(3)')
})
it('loads once initially and once for query, reset, status tab and pagination', async () => {
const wrapper = mountView()
await flushPromises()
@@ -62,9 +62,9 @@ describe('UnifiedOrdersView contract', () => {
it('exposes the product main statuses including dropship palletizing', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '待客户确认', '已确认数据', '待上传资料', '一件代发打托', '待分柜', '装柜中', '装柜完成', '出口报关', '已开船/待清关', '待拆柜', '待排车', '出库单', '派送中', '签收完成',
'待审核', '待测量数据', '一件代发打托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
])
for (const legacyLabel of ['差异处理中', '进入分柜']) {
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
}
})
@@ -629,7 +629,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('feePanelRevision.value += 1')
expect(source).not.toContain('<h3 class="fee-section-title">头程自动初算</h3>')
expect(source).toContain('其他费用明细')
expect(source).toContain('@changed="loadDetail(true); loadStatusCounts()"')
expect(source).toContain('@changed="loadDetail(true)"')
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
@@ -11,9 +11,6 @@ import {
getPlatformUnifiedOrderBatchApprovalPreview,
updatePlatformUnifiedOrderRemarks,
listPlatformUnifiedOrders,
getPlatformUnifiedOrderStatusCounts,
type UnifiedOrderStatusCountQuery,
type UnifiedOrderStatusCounts,
updatePlatformUnifiedOrderPlanning,
unifiedOrderStatusOptions,
type UnifiedOrderDetailResource,
@@ -124,10 +121,6 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const statusCounts = ref<UnifiedOrderStatusCounts['counts']>({})
const statusCountsLoading = ref(false)
const statusCountsError = ref<ApiErrorDetails | null>(null)
let statusCountsRequestSerial = 0
const loadError = ref<ApiErrorDetails | null>(null)
const legacyImportOpen = ref(false)
const legacyImportFile = ref<File | null>(null)
@@ -721,16 +714,11 @@ function optionalBoolean(value: string | null | undefined) {
function selectStatus(status: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
if (selectedTab.value === status) return
const leavingMeasurementReview = measurementReviewSelected.value
measurementReviewSelected.value = status === 'MEASUREMENT_REVIEW'
if (measurementReviewSelected.value) selectedOrders.value = []
if (status === 'MEASUREMENT_REVIEW') {
void loadStatusCounts()
return
}
if (status === 'MEASUREMENT_REVIEW') return
selectedStatus.value = status
if (leavingMeasurementReview) void loadStatusCounts()
if (page.value === 1) void loadOrders(false)
if (page.value === 1) void loadOrders()
else page.value = 1
}
@@ -798,7 +786,6 @@ async function savePlanning(row: UnifiedOrderResource) {
row.estimatedVolumeCbm = estimatedVolumeCbm
draft.savedExpectedArrivalAt = draft.expectedArrivalAt
draft.savedEstimatedVolumeCbm = estimatedVolumeCbm
void loadStatusCounts()
ElMessage.success('订单运营预估已更新')
} catch (error) {
const details = getApiErrorDetails(error, '订单运营预估保存失败')
@@ -834,7 +821,6 @@ const canEditDeclaration = computed(() => Boolean(
))
async function handleDeclarationUpdated() {
void loadStatusCounts()
activeDetailTab.value = 'basic'
await loadDetail(true)
}
@@ -1103,7 +1089,6 @@ async function updateMeasurementCalculationMode(row: Record<string, unknown> | n
measurementSectionModes.outer = mode
measurementLoadedKey.value = ''
ElMessage.success(`${row ? '当前数据' : '全部测量数据'}已保存并绑定计算规则,体积已重新计算`)
void loadStatusCounts()
return true
} catch (error) {
measurementLoadedKey.value = ''
@@ -1156,7 +1141,6 @@ async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
if (refreshed) measurementDetail.value = refreshed
measurementLoadedKey.value = ''
ElMessage.success(`序号 ${row.itemNo || '—'} 的外箱规已更新,相关数值已重新计算`)
void loadStatusCounts()
} catch (error) {
measurementLoadedKey.value = ''
await loadMeasurementDetails()
@@ -1599,50 +1583,7 @@ async function loadWarehouses() {
}
}
function orderCountFilters(): UnifiedOrderStatusCountQuery {
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
return {
tenantId: activeFilters.tenantId || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
}
}
async function loadStatusCounts() {
const serial = ++statusCountsRequestSerial
statusCounts.value = {}
statusCountsLoading.value = true
statusCountsError.value = null
try {
const result = await getPlatformUnifiedOrderStatusCounts(orderCountFilters())
if (serial === statusCountsRequestSerial) statusCounts.value = result.counts
} catch (error) {
if (serial === statusCountsRequestSerial) statusCountsError.value = getApiErrorDetails(error, '订单页签数量加载失败')
} finally {
if (serial === statusCountsRequestSerial) statusCountsLoading.value = false
}
}
function tabCount(value: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
return value ? statusCounts.value[value] : undefined
}
async function loadOrders(refreshCounts = true) {
if (refreshCounts) void loadStatusCounts()
async function loadOrders() {
if (measurementReviewSelected.value) return
selectedApprovalOrders.value = []
selectedOrders.value = []
@@ -1651,9 +1592,26 @@ async function loadOrders(refreshCounts = true) {
coLoadError.value = null
Object.keys(planningDrafts).forEach((key) => delete planningDrafts[key])
try {
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
const result = await listPlatformUnifiedOrders({
...orderCountFilters(),
tenantId: activeFilters.tenantId || undefined,
mainStatus: selectedStatus.value || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize.value,
})
@@ -1670,19 +1628,17 @@ async function loadOrders(refreshCounts = true) {
function search() {
appliedFilters.value = cloneFilters(filters.value)
void loadStatusCounts()
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders(false)
void loadOrders()
}
function reset() {
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
void loadStatusCounts()
if (page.value === 1) void loadOrders(false)
if (page.value === 1) void loadOrders()
else page.value = 1
}
@@ -1850,7 +1806,6 @@ async function executeConfirmAndSendFee() {
automaticFee.value = { feeStatus: snapshot.feeStatus, totalAmount: Number(snapshot.totalAmount || 0) }
feePanelRevision.value += 1
ElMessage.success('计费量和单价已确认并发送给客户')
void loadStatusCounts()
feeConfirmOpen.value = false
feeConfirmAttempt = null
await loadDetail(true)
@@ -2343,7 +2298,7 @@ async function loadDetail(force = false) {
}
}
watch([page, pageSize], () => { void loadOrders(false) })
watch([page, pageSize], () => { void loadOrders() })
watch(businessInboundCode, (value) => {
detailAssociationRequestSerial += 1
resolvedDetailTarget.value = null
@@ -2366,16 +2321,12 @@ watch(detailRouteKey, (value) => {
watch(measurementPreviewOpen, (open) => {
if (!open) releaseMeasurementPreview()
})
watch(smartLoadingOpen, (open, wasOpen) => {
if (wasOpen && !open) void loadOrders()
})
onMounted(() => {
void loadCustomers()
void loadWarehouses()
if (!isDetailRoute.value) void loadOrders()
})
onBeforeUnmount(() => {
statusCountsRequestSerial += 1
detailAssociationRequestSerial += 1
measurementRequestSerial += 1
holdRequestSerial += 1
@@ -2580,7 +2531,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
:inbound-id="normalizedInboundId"
:destination-type="detail.summary.destinationType"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true); loadStatusCounts()"
@changed="loadDetail(true)"
/>
</template>
</template>
@@ -2777,9 +2728,9 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<template v-else>
<div v-if="measurementReviewSelected" id="unified-orders-results" role="tabpanel">
<InboundTaskList mode="review" title="订单列表" @changed="loadStatusCounts">
<InboundTaskList mode="review" title="订单列表">
<template #status-tabs>
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
@@ -2791,13 +2742,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
><span>{{ tab.label }}</span><span v-if="tabCount(tab.value) !== undefined" class="order-status-count">({{ tabCount(tab.value) }})</span></button>
</div>
<div v-if="statusCountsLoading" class="order-status-feedback" role="status">页签数量加载中…</div>
<div v-else-if="statusCountsError" class="order-status-feedback" role="status">
<span>{{ statusCountsError.message }}</span>
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
<ApiErrorMeta :details="statusCountsError" />
>{{ tab.label }}</button>
</div>
</template>
</InboundTaskList>
@@ -2812,7 +2757,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<template #toolbar-start><h1>订单列表</h1></template>
<template #filters>
<div class="orders-query-panel integrated-query-group">
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
@@ -2824,15 +2769,9 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
><span>{{ tab.label }}</span><span v-if="tabCount(tab.value) !== undefined" class="order-status-count">({{ tabCount(tab.value) }})</span></button>
>{{ tab.label }}</button>
</div>
<div v-if="statusCountsLoading" class="order-status-feedback" role="status">页签数量加载中…</div>
<div v-else-if="statusCountsError" class="order-status-feedback" role="status">
<span>{{ statusCountsError.message }}</span>
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
<ApiErrorMeta :details="statusCountsError" />
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
@@ -1,6 +1,4 @@
/* Order-specific sections. Shared density lives in styles/adminDensity.css. */
.unified-orders-page .order-status-count { font-variant-numeric: tabular-nums; }
.unified-orders-page .order-status-feedback { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.unified-orders-page .inbound-fee-panel .fee-summary {
grid-template-columns: minmax(230px, 1.3fr) repeat(4, minmax(150px, 1fr));
border-top: 0;
-1
View File
@@ -48,7 +48,6 @@ const overseasConsumers = [
const overseasBusinessConsumers = [
'overseas-business/pages/PurchaseOrdersView.vue', 'overseas-business/pages/PurchaseOrderDetailView.vue',
'overseas-business/pages/PurchaseOrderEditView.vue', 'overseas-business/pages/TransportOrdersView.vue',
'overseas-business/pages/TruckOrderDetailView.vue',
'overseas-business/pages/TransportOrderFormView.vue', 'overseas-business/components/ProcurementNodeActions.vue', 'overseas-business/components/ProcurementPhysicalFields.vue', 'overseas-business/components/ProcurementTransportFields.vue', 'overseas-business/components/ProcurementFinanceFields.vue', 'overseas-business/components/ProcurementChargeFields.vue', 'overseas-business/components/ProcurementControlFields.vue',
]
const customerServiceConsumers = [