优化
This commit is contained in:
@@ -155,3 +155,8 @@ export async function updateLocalLooseboardOrder(orderNo: string, customerId: st
|
||||
const response = await http.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return looseboardDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function reviewLocalLooseboardOrder(orderNo: string, customerId: string, payload: {version: number; decision: 'APPROVE' | 'REJECT'; idempotencyKey: string; remark?: string}) {
|
||||
const response = await http.post<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } })
|
||||
return looseboardDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getLocalLooseboardOrder, reviewLocalLooseboardOrder } from '../api/platformOverseasBusiness'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder } from '../types'
|
||||
const open = defineModel<boolean>({default: false})
|
||||
const props = defineProps<{order: LocalLooseboardOrder | null}>()
|
||||
const emit = defineEmits<{saved: []}>()
|
||||
const detail = ref<LocalLooseboardDetail | null>(null), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE'), remark = ref('')
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (!props.order) return
|
||||
const current=++generation; loading.value=true; detail.value=null; error.value=null
|
||||
try { const value=await getLocalLooseboardOrder(props.order.orderNo,props.order.customerId); if(current===generation)detail.value=value }
|
||||
catch(cause) { if(current===generation)error.value=getApiErrorDetails(cause,'散板订单读取失败') }
|
||||
finally { if(current===generation)loading.value=false }
|
||||
}
|
||||
async function submit() {
|
||||
if (!detail.value || busy.value) return
|
||||
if (decision.value==='REJECT' && !remark.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
busy.value=true; error.value=null
|
||||
try {
|
||||
await reviewLocalLooseboardOrder(detail.value.orderNo,detail.value.customerId,{version:detail.value.version,decision:decision.value,remark:remark.value.trim() || undefined,idempotencyKey:crypto.randomUUID()})
|
||||
ElMessage.success(decision.value==='APPROVE'?'审核通过,已生成关联收货任务':'订单已驳回'); open.value=false; emit('saved')
|
||||
} catch(cause) { error.value=getApiErrorDetails(cause,'散板审核失败'); await load().catch(()=>undefined); error.value=getApiErrorDetails(cause,'散板审核失败') }
|
||||
finally { busy.value=false }
|
||||
}
|
||||
watch(open,value=>{if(value){decision.value='APPROVE';remark.value='';void load()}else generation++})
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="审核本土散板订单" width="680px" class="admin-density-dialog admin-workbench" :confirm-loading="busy" :confirm-disabled="loading || !detail || detail.status !== 'PENDING_ACCEPTANCE'" lock-while-submitting @confirm="submit">
|
||||
<LoadErrorState v-if="error" title="审核暂未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form v-if="detail" label-position="top" :disabled="busy" class="density-form">
|
||||
<el-form-item label="订单号">{{detail.orderNo}}</el-form-item>
|
||||
<el-form-item label="入仓方式">{{detail.deliveryMode==='WAREHOUSE_PICKUP'?'仓库上门取货':detail.deliveryMode==='DELIVERY_TO_WAREHOUSE'?'送货到仓':'未填写,需先编辑补齐'}}</el-form-item>
|
||||
<el-form-item label="审核结果"><el-radio-group v-model="decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="审核意见" :required="decision==='REJECT'"><el-input v-model="remark" type="textarea" maxlength="1000" :rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog></template>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||
@@ -16,7 +17,20 @@ const props = withDefaults(defineProps<{
|
||||
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
|
||||
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([]), warehouseError = ref('')
|
||||
async function loadWarehouses() {
|
||||
if (props.kind !== 'looseboard') return
|
||||
try { warehouses.value = (await listPlatformWarehouses({page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE'})).records; warehouseError.value = '' }
|
||||
catch (cause) { warehouseError.value = cause instanceof Error ? cause.message : '仓库读取失败' }
|
||||
}
|
||||
function chooseWarehouse() {
|
||||
const warehouse = warehouses.value.find(row => String(row.id) === String(props.model.operatingWarehouseId))
|
||||
props.model.deliveryContactName = warehouse?.contactName || ''; props.model.deliveryContactPhone = warehouse?.contactPhone || ''
|
||||
}
|
||||
onMounted(loadWarehouses)
|
||||
watch([() => props.model.operatingWarehouseId, warehouses], chooseWarehouse, { immediate: true })
|
||||
const rules = computed(() => transportDemandRules(props.kind, props.model))
|
||||
const isTruck = computed(() => props.kind === 'truck')
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
|
||||
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
|
||||
@@ -26,26 +40,40 @@ defineExpose({
|
||||
<template>
|
||||
<el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||
<slot name="customer" />
|
||||
<FormSection v-if="kind === 'looseboard'" class="density-form-section" title="入仓安排">
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item v-if="kind === 'looseboard' && model.destinationType === 'PRIVATE'" label="目的地邮编" prop="destinationPostalCode"><el-input v-model.trim="model.destinationPostalCode" maxlength="32" /></el-form-item>
|
||||
<el-form-item class="transport-demand-wide" label="送货方式" prop="deliveryMode"><el-radio-group v-model="model.deliveryMode"><el-radio-button value="DELIVERY_TO_WAREHOUSE">送货到仓</el-radio-button><el-radio-button value="WAREHOUSE_PICKUP">仓库上门取货</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="收货仓库" prop="operatingWarehouseId"><el-select v-model="model.operatingWarehouseId" filterable placeholder="请选择收货仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select><span v-if="warehouseError" role="alert">{{ warehouseError }} <el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
|
||||
<template v-if="model.deliveryMode === 'DELIVERY_TO_WAREHOUSE'">
|
||||
<el-form-item label="到仓联系人" prop="deliveryContactName"><el-input v-model="model.deliveryContactName" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
|
||||
<el-form-item label="到仓联系电话" prop="deliveryContactPhone"><el-input v-model="model.deliveryContactPhone" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
|
||||
<el-form-item label="预计到货时间" prop="estimatedArrivalAt"><el-date-picker v-model="model.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item>
|
||||
<el-form-item label="物流跟踪号" prop="trackingNo"><el-input v-model.trim="model.trackingNo" maxlength="255" /></el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection class="density-form-section" title="运输信息">
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||
<el-form-item v-if="isTruck" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item :label="isTruck ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="isTruck ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="isTruck ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点取货地址' : '起运详细地址'" prop="originAddress"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item :label="isTruck ? 'C点送达地址' : '目的详细地址'" prop="destinationAddress"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="货物信息">
|
||||
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
|
||||
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck'" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" /></el-form-item>
|
||||
<el-form-item v-if="isTruck" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
|
||||
@@ -58,8 +86,8 @@ defineExpose({
|
||||
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
|
||||
</div></el-form-item>
|
||||
<div />
|
||||
<el-form-item :label="kind === 'truck' ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item :label="isTruck ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item :label="isTruck ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
|
||||
@@ -36,6 +36,22 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('looseboard document fees', () => {
|
||||
it('refreshes the current status when returning to a cached document tab', async () => {
|
||||
const visible = ref(true)
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND' }))
|
||||
.mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH' }))
|
||||
const wrapper = mount({ components: { LooseboardOrderDetailView }, setup: () => ({ visible }),
|
||||
template: '<KeepAlive><LooseboardOrderDetailView v-if="visible" /></KeepAlive>',
|
||||
}, { global: { plugins: [ElementPlus], stubs: { BusinessDocumentFees: true, FleetArrangementSummary: true, DataTable: true } } })
|
||||
await flushPromises()
|
||||
expect(getLocalLooseboardOrder).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.status-badge').text()).toBe('待入仓')
|
||||
visible.value = false; await flushPromises()
|
||||
visible.value = true; await flushPromises()
|
||||
expect(getLocalLooseboardOrder).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('.status-badge').text()).toBe('待派车')
|
||||
})
|
||||
|
||||
it('loads the customer scoped document and passes its exact identity to fees', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onActivated, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -14,6 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import TransferPickupProgressPanel from '@/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue'
|
||||
import { poolOrderStatusLabels } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
|
||||
|
||||
@@ -58,6 +59,12 @@ async function load() {
|
||||
finally { if (sequence === requestSequence) loading.value = false }
|
||||
}
|
||||
watch([orderNo, customerId], load, { immediate: true })
|
||||
// 首次由路由加载;缓存标签重新打开时读取PDA和排车已推进的最新状态。
|
||||
let activatedOnce = false
|
||||
onActivated(() => {
|
||||
if (activatedOnce) void load()
|
||||
activatedOnce = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -73,6 +80,7 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
|
||||
<section v-if="detail.deliveryMode === 'WAREHOUSE_PICKUP' && detail.transferNo" class="looseboard-detail-section"><el-alert title="上门取货需在对应取货排车的作业节点上传取货及到仓资料。" type="info" :closable="false" /><TransferPickupProgressPanel :transfer-no="detail.transferNo" :customer-id="detail.customerId" /></section>
|
||||
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-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="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
|
||||
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
|
||||
@@ -60,7 +60,7 @@ async function load() {
|
||||
if (isLooseboard.value) {
|
||||
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
|
||||
Object.assign(form, { deliveryMode: detail.deliveryMode || 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: detail.operatingWarehouseId || '', destinationType: detail.destinationType || 'PRIVATE', destinationPostalCode: detail.destinationPostalCode || '', deliveryContactName: detail.deliveryContactName || '', deliveryContactPhone: detail.deliveryContactPhone || '', estimatedArrivalAt: detail.estimatedArrivalAt || '', trackingNo: detail.trackingNo || '', customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
|
||||
} else {
|
||||
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
|
||||
@@ -53,10 +53,12 @@ describe('transport document entry', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
|
||||
})
|
||||
|
||||
it('does not expose looseboard editing from the list even when permission exists', async () => {
|
||||
it('lets authorized users complete missing receiving information before review', async () => {
|
||||
const wrapper = mountList('looseboard', true); await flushPromises()
|
||||
const actions = wrapper.findComponent(RowActions)
|
||||
expect(actions.text()).not.toContain('编辑')
|
||||
expect(actions.text()).toContain('编辑')
|
||||
actions.vm.$emit('action', 'edit'); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-edit', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
|
||||
})
|
||||
|
||||
it('keeps truck mode on its own list API', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 LooseboardReviewDialog from '../components/LooseboardReviewDialog.vue'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -23,6 +24,7 @@ import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const reviewOpen = ref(false), reviewOrder = ref<LocalLooseboardOrder | null>(null)
|
||||
const routeOpen = ref(false)
|
||||
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
@@ -104,8 +106,8 @@ async function load() {
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !isLooseboard.value && !row.fleetArrangement && row.status === 'PENDING_REVIEW' }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' && !(row as LocalLooseboardOrder).deliveryMode : 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 openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-detail' : 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
|
||||
@@ -114,6 +116,8 @@ function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 10 },
|
||||
{ key: 'review', label: '审核', visible: row.status === 'PENDING_ACCEPTANCE' && access.can(overseasBusinessPermissions.looseboardStatus), priority: 20 },
|
||||
{ key: 'edit', label: '编辑', visible: row.status === 'PENDING_ACCEPTANCE' && !row.deliveryMode && access.can(overseasBusinessPermissions.looseboardEdit) },
|
||||
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
|
||||
]
|
||||
}
|
||||
@@ -124,7 +128,8 @@ function truckActions(row: LocalTransportOrder): RowActionItem[] {
|
||||
]
|
||||
}
|
||||
function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
if (key === 'edit') openEdit(row)
|
||||
if (key === 'review' && isLooseboard.value) { reviewOrder.value=row as LocalLooseboardOrder; reviewOpen.value=true }
|
||||
else if (key === 'edit') openEdit(row)
|
||||
else if (key === 'arrangement') showArrangement(row)
|
||||
else if (key === 'detail') openDetail(row)
|
||||
}
|
||||
@@ -134,6 +139,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LooseboardReviewDialog v-model="reviewOpen" :order="reviewOrder" @saved="load" />
|
||||
<AdminPage class="admin-density admin-workbench"><ListPageLayout :aria-label="`${domainLabel}订单列表`">
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { FormRules } from 'element-plus'
|
||||
|
||||
export type TransportDemandKind = 'truck' | 'looseboard'
|
||||
export interface TransportDemandForm {
|
||||
deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
operatingWarehouseId: string
|
||||
deliveryContactName: string
|
||||
deliveryContactPhone: string
|
||||
estimatedArrivalAt: string
|
||||
trackingNo: string
|
||||
destinationType: 'FBA' | 'PRIVATE'
|
||||
destinationPostalCode: string
|
||||
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
|
||||
origin: string
|
||||
destination: string
|
||||
@@ -25,6 +33,7 @@ export interface TransportDemandForm {
|
||||
|
||||
export function createTransportDemandForm(): TransportDemandForm {
|
||||
return {
|
||||
deliveryMode: 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: '', deliveryContactName: '', deliveryContactPhone: '', estimatedArrivalAt: '', trackingNo: '', destinationType: 'PRIVATE', destinationPostalCode: '',
|
||||
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
|
||||
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
|
||||
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
|
||||
@@ -32,11 +41,9 @@ export function createTransportDemandForm(): TransportDemandForm {
|
||||
}
|
||||
}
|
||||
|
||||
const requiredText = (kind: TransportDemandKind) => ({
|
||||
origin: kind === 'truck' ? '取货地' : '起运地',
|
||||
const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
|
||||
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
|
||||
destination: kind === 'truck' ? '送达地' : '目的地',
|
||||
originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人',
|
||||
originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话',
|
||||
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
|
||||
destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
|
||||
} as const)
|
||||
@@ -47,10 +54,21 @@ export function localMinuteInput(value: unknown) {
|
||||
|
||||
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||
const rules: FormRules<TransportDemandForm> = {}
|
||||
const labels = requiredText(kind)
|
||||
const labels = requiredText(kind, form)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||
}
|
||||
if (kind === 'looseboard') {
|
||||
if (form.deliveryMode === 'WAREHOUSE_PICKUP') rules.originAddress = [{ required: true, whitespace: true, message: '请输入起运详细地址', trigger: 'blur' }]
|
||||
rules.operatingWarehouseId = [{ required: true, message: '请选择收货仓库', trigger: 'change' }]
|
||||
if (form.destinationType === 'PRIVATE') {
|
||||
rules.destinationAddress = [{ required: true, whitespace: true, message: '请输入目的详细地址', trigger: 'blur' }]
|
||||
rules.destinationPostalCode = [{ required: true, whitespace: true, message: '请输入目的地邮编', trigger: 'blur' }]
|
||||
}
|
||||
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE') {
|
||||
for (const [field, label] of [['deliveryContactName', '到仓联系人'], ['deliveryContactPhone', '到仓联系电话'], ['estimatedArrivalAt', '预计到货时间']] as const) rules[field] = [{ required: true, message: '请填写' + label, trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
if (field === 'packageCount' && kind === 'truck') continue
|
||||
@@ -66,8 +84,16 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
}
|
||||
|
||||
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
|
||||
if (kind === 'looseboard') {
|
||||
if (!form.operatingWarehouseId) return '请选择收货仓库'
|
||||
if (!['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP'].includes(form.deliveryMode)) return '请选择送货方式'
|
||||
if (form.destinationType === 'PRIVATE' && (!form.destinationAddress.trim() || !form.destinationPostalCode.trim())) return '请填写目的详细地址和邮编'
|
||||
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' && (!form.deliveryContactName.trim() || !form.deliveryContactPhone.trim() || !form.estimatedArrivalAt)) return '送货到仓须填写仓库联系人和预计到货时间'
|
||||
if (form.deliveryMode === 'WAREHOUSE_PICKUP' && !form.originAddress.trim()) return '请输入起运详细地址'
|
||||
if (form.packageCount < 1) return '散板收货件数须大于 0'
|
||||
}
|
||||
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
|
||||
const labels = requiredText(kind)
|
||||
const labels = requiredText(kind, form)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
|
||||
}
|
||||
@@ -102,15 +128,15 @@ function commonPayload(form: TransportDemandForm) {
|
||||
|
||||
type CommonPayload = ReturnType<typeof commonPayload>
|
||||
type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
|
||||
type LooseboardPayload = CommonPayload & { deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'; operatingWarehouseId: string; destinationType: 'FBA' | 'PRIVATE'; destinationPostalCode?: string; deliveryContactName?: string; deliveryContactPhone?: string; estimatedArrivalAt?: string; trackingNo?: string; productName: string | undefined; estimatedPalletCount: number }
|
||||
|
||||
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
|
||||
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
|
||||
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
|
||||
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm): TruckPayload | LooseboardPayload {
|
||||
const failure = transportDemandError(kind, form)
|
||||
if (failure) throw new Error(failure)
|
||||
const common = commonPayload(form)
|
||||
return kind === 'truck'
|
||||
? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
: { ...common, ...(form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? { origin: '', originAddress: undefined, originContactName: '', originContactPhone: '' } : {}), deliveryMode: form.deliveryMode, operatingWarehouseId: form.operatingWarehouseId, deliveryContactName: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactName.trim() : undefined, deliveryContactPhone: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactPhone.trim() : undefined, estimatedArrivalAt: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? localMinuteInput(form.estimatedArrivalAt) : undefined, trackingNo: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.trackingNo.trim() || undefined : undefined, destinationType: form.destinationType, destinationPostalCode: form.destinationType === 'PRIVATE' ? form.destinationPostalCode.trim() : undefined, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ export interface LocalLooseboardQuery {
|
||||
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalLooseboardOrder {
|
||||
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
operatingWarehouseId?: string
|
||||
destinationType?: 'FBA' | 'PRIVATE'
|
||||
destinationPostalCode?: string
|
||||
deliveryContactName?: string
|
||||
deliveryContactPhone?: string
|
||||
estimatedArrivalAt?: string
|
||||
trackingNo?: string
|
||||
transferNo?: string
|
||||
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
|
||||
transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number
|
||||
@@ -84,6 +94,16 @@ export interface LocalLooseboardDetail extends LocalLooseboardOrder {
|
||||
histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface CreateLocalLooseboardOrderRequest {
|
||||
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
operatingWarehouseId?: string
|
||||
destinationType?: 'FBA' | 'PRIVATE'
|
||||
destinationPostalCode?: string
|
||||
deliveryContactName?: string
|
||||
deliveryContactPhone?: string
|
||||
estimatedArrivalAt?: string
|
||||
trackingNo?: string
|
||||
transferNo?: string
|
||||
|
||||
productName?: string | null; estimatedPalletCount: number
|
||||
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
packageCount: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getDispatch, listLooseboardCargo, addLooseboardCargo, expandSelectionGroups, type Candidate, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
const open=defineModel<boolean>({default:false})
|
||||
const props=defineProps<{dispatchNo:string}>()
|
||||
const emit=defineEmits<{saved:[]}>()
|
||||
const rows=ref<Candidate[]>([]), selected=ref<Candidate[]>([]), detail=ref<DispatchDetail|null>(null)
|
||||
const page=ref(1), size=ref(20), total=ref(0), loading=ref(false), saving=ref(false), error=ref<ApiErrorDetails|null>(null)
|
||||
const columns:DataTableColumn[]=[
|
||||
{key:'selection',type:'selection',width:45}, {key:'orderNo',label:'订单号',minWidth:180},
|
||||
{key:'customerName',label:'客户',minWidth:140}, {key:'inboundCode',label:'入仓号',minWidth:150},
|
||||
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>!row.selection.warehousePalletId ? '申报待到仓' : '已实收'}, {key:'palletNo',label:'计划 / 实物托盘',minWidth:150}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
|
||||
{key:'availablePackages',label:'计划 / 可用箱数',width:100},
|
||||
{key:'blockReasons',label:'资格',minWidth:200,formatter:(row:Candidate)=>row.blockReasons.length?row.blockReasons.join(';'):'可添加'},
|
||||
]
|
||||
let generation=0
|
||||
async function load() {
|
||||
const current=++generation;loading.value=true;error.value=null;selected.value=[];rows.value=[];detail.value=null
|
||||
try {
|
||||
const [dispatch,value]=await Promise.all([getDispatch(props.dispatchNo),listLooseboardCargo(props.dispatchNo,page.value,size.value)])
|
||||
if(current!==generation)return
|
||||
if(dispatch.dispatchNo!==props.dispatchNo)throw new Error('排车身份不一致,请重新打开')
|
||||
detail.value=dispatch;rows.value=value.candidates;total.value=value.total
|
||||
}catch(cause){if(current===generation)error.value=getApiErrorDetails(cause,'散板货物读取失败')}
|
||||
finally{if(current===generation)loading.value=false}
|
||||
}
|
||||
async function submit(){
|
||||
if(!detail.value || saving.value || !selected.value.length)return
|
||||
saving.value=true;error.value=null
|
||||
try{
|
||||
if(selected.value.some(row=>row.blockReasons.length))throw new Error('所选货物存在阻断,请重新选择')
|
||||
const cargo=expandSelectionGroups(selected.value)
|
||||
// 完整组来源版本由后端复核;预约批次继续通过原排车预约工作台绑定。
|
||||
await addLooseboardCargo(props.dispatchNo,{recordVersion:detail.value.recordVersion,idempotencyKey:crypto.randomUUID(),cargo,batchAllocations:[]})
|
||||
ElMessage.success('散板货物已加入排车,请按当前货物重新审核');open.value=false;emit('saved')
|
||||
}catch(cause){await load();error.value=getApiErrorDetails(cause,'添加货物失败')}
|
||||
finally{saving.value=false}
|
||||
}
|
||||
watch(open,value=>{if(value){page.value=1;void load()}else generation++})
|
||||
watch([page,size],()=>{if(open.value)void load()})
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="添加散板货物" width="1180px" class="admin-density-dialog admin-workbench" :confirm-loading="saving" :confirm-disabled="loading || !selected.length || !detail?.actions.ADD_CARGO?.allowed" lock-while-submitting @confirm="submit">
|
||||
<LoadErrorState v-if="error" title="添加货物暂未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="detail && !detail.actions.ADD_CARGO?.allowed" :title="detail.actions.ADD_CARGO?.blockReason || '当前排车不可加货,请先退回审核或重新排车'" type="warning" :closable="false" />
|
||||
<el-alert title="申报货物可先加入排车;实际到仓后核对货量,完成实收后才能出库拣货。上门取货的凭证在对应取货排车节点上传。" type="info" :closable="false" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="sourceKey" :page-sizes="[10,20,50]" empty-text="暂无可添加的已审核散板货物" @selection-change="selected=$event" />
|
||||
</AppDialog></template>
|
||||
@@ -2,6 +2,10 @@
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import LooseboardCargoDialog from '../components/LooseboardCargoDialog.vue'
|
||||
const access=useAccessPreviewStore(), cargoOpen=ref(false), cargoDispatchNo=ref('')
|
||||
function addCargo(row:DispatchCapacityDemand){cargoDispatchNo.value=row.dispatchNo;cargoOpen.value=true}
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -16,6 +20,7 @@ const filters = ref<QueryModel>({ origin: '', destination: '' }), applied = ref(
|
||||
const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }]
|
||||
const amount = (value: number | null | undefined) => value == null ? '—' : String(value)
|
||||
const columns: DataTableColumn[] = [
|
||||
{key:'actions',label:'操作',width:120,fixed:'left',slotName:'actions'},
|
||||
{ key: 'dispatchNo', label: '排车单号', minWidth: 200 },
|
||||
{ key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认', afterColumn: 'dispatchNo' }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' },
|
||||
@@ -39,8 +44,10 @@ function search() { applied.value = { ...filters.value }; if (page.value === 1)
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface">
|
||||
<LooseboardCargoDialog v-model="cargoOpen" :dispatch-no="cargoDispatchNo" @saved="load" />
|
||||
<DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求">
|
||||
<template #toolbar-start><h1>散板需求</h1></template>
|
||||
<template #actions="{row}"><el-button v-if="(access.can('platform:fleet:capacity-offer:cargo-add') || access.can('platform:fleet:capacity-offer:manage')) && access.can('platform:overseas-warehouse:dispatch:edit') && access.can('platform:overseas-warehouse:dispatch:list')" link type="primary" @click="addCargo(row as DispatchCapacityDemand)">添加货物</el-button><span v-else>—</span></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #pagination-left>共 {{ total }} 条散板需求</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const overseasMasterDataPermissions = {
|
||||
capacityOfferList: 'platform:fleet:capacity-offer:list',
|
||||
capacityOfferManage: 'platform:fleet:capacity-offer:manage',
|
||||
capacityCargoAdd: 'platform:fleet:capacity-offer:cargo-add',
|
||||
capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create',
|
||||
capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit',
|
||||
capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel',
|
||||
@@ -62,4 +63,3 @@ export const overseasMasterDataPermissions = {
|
||||
vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
|
||||
vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit',
|
||||
} as const
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type LooseboardPhotoPhase = 'PICKUP' | 'ARRIVAL'
|
||||
export interface LooseboardPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: string | number }
|
||||
export interface LooseboardExecutionItem { itemId: string; customerId: string; orderNo: string; destination: string | null; packages: number; pickupPhotos: LooseboardPhoto[]; arrivalPhotos: LooseboardPhoto[]; canPickup: boolean; canArrive: boolean }
|
||||
export interface LooseboardExecution { dispatchNo: string; recordVersion: string | number; stage: string; direct: boolean; items: LooseboardExecutionItem[] }
|
||||
const path = (number: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}/looseboard-execution`
|
||||
const itemPath = (number: string, item: string, phase: LooseboardPhotoPhase) => `${path(number)}/${encodeURIComponent(item)}/${phase}`
|
||||
export async function getLooseboardExecution(number: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(path(number)))
|
||||
}
|
||||
export async function uploadLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, file: File) {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<LooseboardPhoto>, ApiEnvelope<LooseboardPhoto>>(`${itemPath(number, item, phase)}/uploads`, body))
|
||||
}
|
||||
export async function saveLooseboardPhotos(number: string, item: string, phase: LooseboardPhotoPhase, body: { recordVersion: string | number; idempotencyKey: string; fileIds: string[] }) {
|
||||
return unwrapApiResponse(await http.put<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(itemPath(number, item, phase), body))
|
||||
}
|
||||
export async function readLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, photo: LooseboardPhoto) {
|
||||
const blob = await http.get<Blob, Blob>(`${itemPath(number, item, phase)}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
|
||||
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || blob.type !== photo.contentType || !blob.size) throw new Error('散板照片读取失败')
|
||||
return blob
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export async function listDispatches(query: DispatchQuery) {
|
||||
return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query })))
|
||||
}
|
||||
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
|
||||
/** 散板余位页只选择已实收打托且与本车同仓的散板来源。 */
|
||||
export async function listLooseboardCargo(no: string, page = 1, size = 20) {
|
||||
return normalizeCandidateList(unwrapApiResponse(await http.get<ApiEnvelope<CandidateListWire>, ApiEnvelope<CandidateListWire>>(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, { params: { page, size } })))
|
||||
}
|
||||
export const addLooseboardCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, body) }
|
||||
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
|
||||
export async function getDispatchAppointmentAllocations(no: string) {
|
||||
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
|
||||
|
||||
@@ -472,6 +472,15 @@ export async function createManualInboundPlan(payload: ManualInboundPlanPayload,
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
/** 异常退回以本单预计到仓时间创建计划,后端核对原异常及真实客户归属。 */
|
||||
export async function createExceptionReturnInboundPlan(plan: ManualInboundPlanPayload, expectedArrivalAt: string, idempotencyKey: string) {
|
||||
const response = await http.post<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
|
||||
'/platform/v1/last-leg/exception-orders/return-inbound-plans', { plan, expectedArrivalAt },
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) {
|
||||
const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchDetail } from '../dispatchContracts'
|
||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
@@ -22,10 +23,14 @@ const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = r
|
||||
const tab = ref('RECEIPT'), selected = ref('')
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
|
||||
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
|
||||
const looseboardEditor = ref<InstanceType<typeof DispatchLooseboardExecution>>()
|
||||
const directLooseboard = ref(false)
|
||||
const hasLooseboard = computed(() => detail.value?.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD') === true)
|
||||
const tabs = computed(() => [...(hasLooseboard.value ? [{ key: 'LOOSEBOARD', label: '散板取货与送达' }] : []), ...(!directLooseboard.value ? [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }] : [])])
|
||||
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
||||
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
|
||||
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy || looseboardEditor.value?.busy))
|
||||
let sequence = 0
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
async function load() {
|
||||
@@ -36,6 +41,7 @@ async function load() {
|
||||
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
|
||||
if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料')
|
||||
if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING'
|
||||
if (!detail.value && value.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD')) tab.value = 'LOOSEBOARD'
|
||||
detail.value = value
|
||||
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
|
||||
@@ -43,7 +49,7 @@ async function load() {
|
||||
}
|
||||
async function canLeave() {
|
||||
if (busy.value) return false
|
||||
if (!editor.value?.dirty) return true
|
||||
if (!editor.value?.dirty && !looseboardEditor.value?.dirty) return true
|
||||
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
|
||||
}
|
||||
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
|
||||
@@ -52,7 +58,7 @@ function selectTab(value: string) { if (!busy.value) { tab.value = value } }
|
||||
function saved() { emit('saved') }
|
||||
async function loadingSaved() { emit('saved'); await load() }
|
||||
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null; directLooseboard.value = false
|
||||
if (visible && props.dispatchNo) void load()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -60,14 +66,15 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
|
||||
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="detail">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in tabs" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" />
|
||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
|
||||
<el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
|
||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||
</div>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
<p v-if="beforeDeparture && tab === 'RECEIPT'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
|
||||
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
|
||||
import type { LooseboardExecution } from '../api/dispatchLooseboardExecution'
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
|
||||
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false }] })
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getLooseboardExecution).mockResolvedValue(value()) })
|
||||
it('shows pure looseboard photo flow and uploads to its authoritative item and node', async () => {
|
||||
vi.mocked(uploadLooseboardPhoto).mockResolvedValue({ fileId: '77', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 10 })
|
||||
vi.mocked(saveLooseboardPhotos).mockResolvedValue({ ...value(), stage: 'DELIVERING' })
|
||||
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('全车为散板')
|
||||
expect(wrapper.findAll('input[type=file]')).toHaveLength(2)
|
||||
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
|
||||
const input = wrapper.get('input[type=file]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(uploadLooseboardPhoto).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.any(File))
|
||||
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', { recordVersion: '3', idempotencyKey: expect.any(String), fileIds: ['77'] })
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps pickup upload visible but disabled until mixed warehouse cargo departs', async () => {
|
||||
const snapshot = value(false)
|
||||
snapshot.stage = 'LOADING'
|
||||
snapshot.items[0]!.canPickup = false
|
||||
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
|
||||
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('混排货物按原流程')
|
||||
expect(wrapper.text()).toContain('上传取货照片')
|
||||
expect(wrapper.text()).toContain('完成仓内拣货并确认装车后可上传')
|
||||
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
|
||||
expect(input.attributes('disabled')).toBeDefined()
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
|
||||
input.element.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
expect(uploadLooseboardPhoto).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('enables pickup upload for mixed cargo in delivery', async () => {
|
||||
vi.mocked(getLooseboardExecution).mockResolvedValue(value(false))
|
||||
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('enables mixed pickup from after loading using server eligibility', async () => {
|
||||
const snapshot = value(false)
|
||||
snapshot.stage = 'AFTER_LOADING'
|
||||
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
|
||||
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps upload available after saving and appends multiple files to the current list', async () => {
|
||||
const snapshot = value(false)
|
||||
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
|
||||
snapshot.items[0]!.pickupPhotos = [old]
|
||||
snapshot.items[0]!.canArrive = true
|
||||
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
|
||||
vi.mocked(uploadLooseboardPhoto)
|
||||
.mockResolvedValueOnce({ ...old, fileId: '78', fileName: 'new1.png' })
|
||||
.mockResolvedValueOnce({ ...old, fileId: '79', fileName: 'new2.png' })
|
||||
vi.mocked(saveLooseboardPhotos).mockResolvedValue(snapshot)
|
||||
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
|
||||
expect(input.attributes('disabled')).toBeUndefined()
|
||||
Object.defineProperty(input.element, 'files', { value: ['new1.png', 'new2.png'].map(name => new File(['test'], name, { type: 'image/png' })), configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(uploadLooseboardPhoto).toHaveBeenCalledTimes(2)
|
||||
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['77', '78', '79'] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('deletes one photo while preserving the others in the versioned save', async () => {
|
||||
const snapshot = value(false)
|
||||
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
|
||||
snapshot.items[0]!.pickupPhotos = [old, { ...old, fileId: '78', fileName: 'keep.png' }]
|
||||
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
|
||||
const updated = value(false)
|
||||
updated.items[0]!.pickupPhotos = [snapshot.items[0]!.pickupPhotos[1]!]
|
||||
vi.mocked(saveLooseboardPhotos).mockResolvedValue(updated)
|
||||
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
await wrapper.get('button[aria-label="删除取货照片old.png"]').trigger('click'); await flushPromises()
|
||||
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['78'] }))
|
||||
expect(wrapper.text()).not.toContain('old.png')
|
||||
expect(wrapper.text()).not.toContain('keep.png')
|
||||
expect(wrapper.find('button[aria-label="删除取货照片keep.png"]').exists()).toBe(true)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const previews = ref<Record<string, string>>({})
|
||||
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
|
||||
const dirty = computed(() => Boolean(pending.value?.photos.length))
|
||||
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
|
||||
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
|
||||
if (busy.value) return '正在处理,请稍候'
|
||||
if (pending.value) return '请先保存已上传照片'
|
||||
if ((phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length >= 20) return '每个节点最多20张照片'
|
||||
if (phase === 'PICKUP' ? item.canPickup : item.canArrive) return ''
|
||||
if (phase === 'ARRIVAL' && !item.pickupPhotos.length) return '请先上传取货照片'
|
||||
if (phase === 'PICKUP' && !snapshot.value?.direct && snapshot.value?.stage === 'LOADING') return '完成仓内拣货并确认装车后可上传'
|
||||
return '当前状态不可上传'
|
||||
}
|
||||
const number = props.dispatchNo
|
||||
let active = true
|
||||
function accept(value: LooseboardExecution) {
|
||||
if (!active || number !== props.dispatchNo || value.dispatchNo !== number) throw new Error('排车身份已变化,请重新打开')
|
||||
snapshot.value = value; emit('mode', value.direct)
|
||||
}
|
||||
async function images() {
|
||||
for (const item of snapshot.value?.items || []) for (const phase of phases) {
|
||||
const photos = phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
|
||||
await Promise.allSettled(photos.map(async photo => {
|
||||
if (previews.value[photo.fileId]) return
|
||||
const blob = await readLooseboardPhoto(number, item.itemId, phase.key, photo)
|
||||
if (active) previews.value[photo.fileId] = URL.createObjectURL(blob)
|
||||
}))
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { accept(await getLooseboardExecution(number)); await images() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '散板履约资料读取失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!pending.value?.photos.length || !snapshot.value || busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
const value = pending.value
|
||||
try {
|
||||
const item = snapshot.value.items.find(row => row.itemId === value.itemId)
|
||||
if (!item) throw new Error('散板明细已变化,请重新打开')
|
||||
const existing = value.phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
|
||||
accept(await saveLooseboardPhotos(number, value.itemId, value.phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: [...existing, ...value.photos].map(photo => photo.fileId) }))
|
||||
pending.value = null; await images(); ElMessage.success('散板照片已保存'); emit('saved')
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '保存失败,已上传照片保留,请核对当前状态后重试')
|
||||
try {
|
||||
accept(await getLooseboardExecution(number)); await images()
|
||||
const item = snapshot.value?.items.find(row => row.itemId === value.itemId)
|
||||
const bound = (value.phase === 'PICKUP' ? item?.pickupPhotos : item?.arrivalPhotos) || []
|
||||
if (value.photos.every(photo => bound.some(file => file.fileId === photo.fileId))) { pending.value = null; emit('saved') }
|
||||
} catch { /* 保留照片和当前错误,用户可重新读取。 */ }
|
||||
}
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
async function upload(itemId: string, phase: LooseboardPhotoPhase, event: Event) {
|
||||
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
|
||||
const item = snapshot.value?.items.find(row => row.itemId === itemId)
|
||||
if (!files.length || !item || uploadReason(item, phase) || !access.can(permission.dispatchEdit)) return
|
||||
const existing = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
|
||||
if (files.length + existing.length > 20 || files.some(file => !['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('每个节点最多20张JPG或PNG,每张不超过10MiB'); return }
|
||||
busy.value = true; error.value = null; pending.value = { itemId, phase, photos: [] }; let failed = false
|
||||
try { for (const file of files) { const photo = await uploadLooseboardPhoto(number, itemId, phase, file); if (!active) return; pending.value.photos.push(photo) } }
|
||||
catch (cause) { failed = true; error.value = getApiErrorDetails(cause, '照片上传失败,已成功上传的照片可继续保存'); if (!pending.value?.photos.length) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
if (!failed && pending.value?.photos.length) await save()
|
||||
}
|
||||
async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase, fileId: string) {
|
||||
if (!snapshot.value || busy.value || pending.value || !access.can(permission.dispatchEdit) || !(phase === 'PICKUP' ? item.canPickup : item.canArrive)) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const photos = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
|
||||
accept(await saveLooseboardPhotos(number, item.itemId, phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: photos.filter(photo => photo.fileId !== fileId).map(photo => photo.fileId) }))
|
||||
await images(); ElMessage.success('照片已删除'); emit('saved')
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '照片删除失败,请刷新后重试')
|
||||
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留原错误。 */ }
|
||||
}
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
|
||||
defineExpose({ busy, dirty })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="looseboard-execution">
|
||||
<el-button :disabled="busy" @click="load">刷新资料</el-button>
|
||||
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
|
||||
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
|
||||
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
|
||||
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
|
||||
<h4>{{ phase.label }}</h4>
|
||||
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
|
||||
上传{{ phase.label }}
|
||||
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
|
||||
</label>
|
||||
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
|
||||
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
|
||||
<div v-else class="node-photos">
|
||||
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
|
||||
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
|
||||
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.looseboard-execution { display: flex; flex-direction: column; gap: 12px; }
|
||||
.looseboard-cargo { border: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.looseboard-cargo > h3 { margin: 0; padding: 8px 12px; font-size: 14px; background: var(--el-fill-color-light); }
|
||||
.looseboard-node { padding: 8px 12px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.looseboard-node h4 { display: inline-block; margin: 0 16px 8px 0; font-size: 14px; }
|
||||
.looseboard-node p { color: var(--el-text-color-secondary); }
|
||||
.node-photos { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 4px; }
|
||||
.node-photo { position: relative; flex: 0 0 160px; width: 160px; height: 100px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); background: var(--el-fill-color-light); }
|
||||
.node-image { display: block; width: 100%; height: 100%; border-radius: inherit; }
|
||||
.node-photo-remove { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: var(--el-color-danger); color: var(--el-color-white); font-size: 20px; line-height: 1; cursor: pointer; }
|
||||
.node-photo-remove:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.node-photo-remove:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
|
||||
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.node-upload.is-disabled input { cursor: not-allowed; }
|
||||
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, D
|
||||
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
|
||||
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
||||
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
||||
export { listLooseboardCargo, addLooseboardCargo } from './api/dispatches'
|
||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||
export { useDispatchCandidates } from './useDispatchCandidates'
|
||||
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
|
||||
|
||||
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
|
||||
|
||||
describe('last-mile order presentation adapter', () => {
|
||||
it('translates raw return order type, source and pending inbound status', () => {
|
||||
const row = toLastMileOrderListItem({ id: '1', customerId: '2', orderNo: 'WC1', orderType: 'DELIVERY_RETURN', orderTypeName: 'DELIVERY_RETURN', currentStatus: 'PENDING_INBOUND', businessSource: { label: 'DELIVERY_RETURN' } } as Parameters<typeof toLastMileOrderListItem>[0])
|
||||
expect(row.orderType).toBe('派送异常退回')
|
||||
expect(row.status).toBe('待入库')
|
||||
expect(row.businessSource).toBe('派送异常退回')
|
||||
})
|
||||
it('keeps completed and processing relabel applications visible together', () => {
|
||||
const row = toLastMileOrderListItem({
|
||||
id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
|
||||
|
||||
@@ -15,6 +15,7 @@ const orderTypeLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING_INBOUND: '待入库',
|
||||
PENDING_REVIEW: '待审核',
|
||||
PENDING_RECEIPT: '待签收入库',
|
||||
PARTIALLY_RECEIVED: '部分入库',
|
||||
@@ -156,13 +157,13 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
orderNo: lastMileOrderReference(dto.orderNo),
|
||||
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
|
||||
businessType: dto.businessSource?.businessType || '',
|
||||
businessSource: text(dto.businessSource?.label),
|
||||
businessSource: lastMileOrderTypeLabel(dto.businessSource?.label),
|
||||
businessOrderNo: text(dto.businessSource?.businessOrderNo),
|
||||
externalBookingNo: text(dto.businessSource?.externalBookingNo),
|
||||
orderTypeCode: String(dto.orderType ?? '').trim(),
|
||||
currentStatusCode: String(dto.currentStatus ?? '').trim(),
|
||||
warning: dto.abnormal ? '异常' : '—',
|
||||
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
|
||||
orderType: lastMileOrderTypeLabel(dto.orderTypeName || dto.orderType),
|
||||
rejectedAt: '—',
|
||||
inboundCode: text(dto.inboundCode),
|
||||
lxCode: text(dto.lxCode),
|
||||
|
||||
@@ -45,6 +45,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
|
||||
{ key: 'sourceBusinessType', label: '业务类型', width: 120, formatter: (row: { sourceBusinessType?: string | null }) => row.sourceBusinessType === 'LOCAL_LOOSEBOARD' ? '本土散板' : row.sourceBusinessType === 'WAREHOUSE_TRANSFER' ? '仓储转运' : '—' },
|
||||
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
|
||||
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
|
||||
|
||||
@@ -124,7 +124,7 @@ const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.
|
||||
const orderFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
const fields: DetailField[] = [
|
||||
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
|
||||
{ label: '履约类型', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
|
||||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||||
{ label: '货主', value: text(order?.customerName) },
|
||||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||||
@@ -149,7 +149,7 @@ function isNonzeroDifference(field: DetailField) { return field.label === '箱
|
||||
const containerFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '运输渠道', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
|
||||
{ label: '运输渠道', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
|
||||
{ label: '集装箱号', value: text(order?.containerNo) },
|
||||
{ label: '托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '箱数', value: Number(order?.packageCount || 0) },
|
||||
@@ -351,7 +351,7 @@ onMounted(load)
|
||||
<section aria-label="商业来源与关联单据">
|
||||
<h2 class="source-heading">关联单据</h2>
|
||||
<dl class="detail-fields density-fields">
|
||||
<div class="wide"><dt>业务来源</dt><dd>{{ text(detail?.order.businessSource?.label) }}</dd></div>
|
||||
<div class="wide"><dt>业务来源</dt><dd>{{ lastMileOrderTypeLabel(detail?.order.businessSource?.label) }}</dd></div>
|
||||
<div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div>
|
||||
<div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
|
||||
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { listContainerArrivalTimes } from '@/modules/container-loading'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
createManualInboundPlan,
|
||||
createExceptionReturnInboundPlan,
|
||||
convertLastMileContainerToInboundPlans,
|
||||
getContainerInboundPlanPreview,
|
||||
getContainerPlanProgress,
|
||||
@@ -46,7 +47,7 @@ vi.mock('../api/lastMileOrders', () => ({
|
||||
getReturnOrderStatistics: vi.fn(),
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
changeContainerReceivingWarehouse: vi.fn(),
|
||||
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
createExceptionReturnInboundPlan: vi.fn(), createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
||||
reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
|
||||
}))
|
||||
@@ -454,6 +455,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
summary,
|
||||
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
||||
})
|
||||
vi.mocked(createExceptionReturnInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
|
||||
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
|
||||
@@ -666,6 +668,30 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(wrapper.text()).not.toContain('尾程订单加载失败')
|
||||
})
|
||||
|
||||
|
||||
it('requires an explicit return ETA and plans the current tail order without a source container', async () => {
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow({ orderNo: 'WC1', sourceOrderNo: 'ZY1', orderTypeCode: 'DELIVERY_RETURN', currentStatusCode: 'PENDING_INBOUND', sourceContainerBookingId: null, inboundCode: null, estimatedWarehouseAt: '', warehouseEta: '—' })])
|
||||
await button(wrapper, '入库')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('WC1')
|
||||
expect(dialog.text()).toContain('ZY1')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createExceptionReturnInboundPlan).not.toHaveBeenCalled()
|
||||
dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-05T14:30:00')
|
||||
await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createExceptionReturnInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ customerId, orderId: '2092873463568596994', expectedOrderVersion: 3 }), '2026-11-05T14:30:00', expect.any(String))
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
expect(listContainerArrivalTimes).not.toHaveBeenCalled()
|
||||
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
|
||||
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
|
||||
import { businessOrderLink } from '../businessOrderLink'
|
||||
import { createExceptionReturnInboundPlan } from '../api/lastMileOrders'
|
||||
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
||||
@@ -111,6 +112,8 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
|
||||
const inboundPlanPreviewLoading = ref(false)
|
||||
const inboundPlanPreviewError = ref('')
|
||||
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
|
||||
const inboundPlanIsExceptionReturn = computed(() => actionTarget.value?.orderTypeCode === 'DELIVERY_RETURN')
|
||||
const exceptionReturnArrival = ref('')
|
||||
const inboundPlanArrival = ref<ContainerArrivalTime | null>(null)
|
||||
const inboundPlanArrivalLoading = ref(false)
|
||||
const inboundPlanArrivalError = ref('')
|
||||
@@ -119,7 +122,7 @@ const inboundPlanArrivalProjection = computed(() => {
|
||||
const arrival = inboundPlanArrival.value
|
||||
return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
})
|
||||
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date
|
||||
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || (inboundPlanIsExceptionReturn.value ? !exceptionReturnArrival.value : !inboundPlanArrivalProjection.value.date)
|
||||
|| (inboundPlanIsDropship.value
|
||||
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
||||
|| inboundPlanSourcePalletCount.value <= 0)))
|
||||
@@ -646,6 +649,7 @@ function openInboundPlan(source?: object) {
|
||||
actionError.value = null
|
||||
inboundPlanArrival.value = null
|
||||
inboundPlanArrivalError.value = ''
|
||||
exceptionReturnArrival.value = target.orderTypeCode === 'DELIVERY_RETURN' ? target.estimatedWarehouseAt || '' : ''
|
||||
inboundPlanOpen.value = true
|
||||
void refreshInboundPlanArrival(target)
|
||||
void refreshManualPalletMinimum(target)
|
||||
@@ -653,6 +657,7 @@ function openInboundPlan(source?: object) {
|
||||
/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */
|
||||
async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
|
||||
const request = ++inboundPlanArrivalRequest
|
||||
if (target.orderTypeCode === 'DELIVERY_RETURN') { inboundPlanArrivalLoading.value = false; return }
|
||||
inboundPlanArrivalLoading.value = true
|
||||
try {
|
||||
if (!target.sourceContainerBookingId) {
|
||||
@@ -676,6 +681,7 @@ async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
|
||||
}
|
||||
}
|
||||
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
|
||||
if (target.orderTypeCode === 'DELIVERY_RETURN') return
|
||||
if (!target.sourceContainerBookingId) return
|
||||
inboundPlanPreviewLoading.value = true
|
||||
inboundPlanPreviewError.value = ''
|
||||
@@ -710,23 +716,26 @@ async function submitInboundPlan() {
|
||||
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
||||
return
|
||||
}
|
||||
if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||
if (!row || !row.overseasWarehouseId || inboundPlanSubmitDisabled.value || (!inboundPlanIsExceptionReturn.value && (!frozenInboundCode || frozenInboundCode === '—')) || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning(inboundPlanIsExceptionReturn.value ? '请选择预计到仓时间,并确认退回仓和计划托数' : '当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
const result = await createManualInboundPlan({
|
||||
const payload = {
|
||||
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
|
||||
palletCount: inboundPlanForm.palletCount,
|
||||
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
|
||||
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
|
||||
remark: inboundPlanForm.remark.trim() || undefined,
|
||||
}, idempotencyKey('manual-inbound-plan'))
|
||||
}
|
||||
const result = inboundPlanIsExceptionReturn.value
|
||||
? await createExceptionReturnInboundPlan(payload, exceptionReturnArrival.value, idempotencyKey('exception-return-plan'))
|
||||
: await createManualInboundPlan(payload, idempotencyKey('manual-inbound-plan'))
|
||||
const palletCodesReady = result.pallets?.length === result.palletCount
|
||||
&& result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo))
|
||||
inboundPlanOpen.value = false; selectedRows.value = []
|
||||
ElMessage.success(palletCodesReady
|
||||
ElMessage.success(inboundPlanIsExceptionReturn.value ? '入库计划已生成,请在异常退回验收中完成收货' : palletCodesReady
|
||||
? '入库计划和托盘码已自动生成'
|
||||
: '入库计划已保存,整柜计划齐全后将自动生成托盘码')
|
||||
await load()
|
||||
@@ -1332,15 +1341,17 @@ onMounted(() => { void load() })
|
||||
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
|
||||
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">{{ inboundPlanIsExceptionReturn ? '异常退回按本次退回安排填写预计到仓时间,计划关联当前尾程订单,收货时从原出库来源恢复库存。' : '优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。' }}</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
<tr v-if="inboundPlanIsExceptionReturn"><th scope="row">尾程单号</th><td>{{ actionTarget?.orderNo }}</td><th scope="row">来源尾程单号</th><td>{{ actionTarget?.sourceOrderNo || '—' }}</td></tr>
|
||||
<tr v-if="inboundPlanIsExceptionReturn"><th scope="row">预计到仓时间 <span class="el-form-item__required">*</span></th><td colspan="3"><el-date-picker v-model="exceptionReturnArrival" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择本次退回预计到仓时间" clearable /></td></tr>
|
||||
<tr v-else><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanIsExceptionReturn ? actionTarget?.orderNo : inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
|
||||
<table class="density-table density-table--editable last-mile-command-table"><tbody>
|
||||
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>{{ inboundPlanIsExceptionReturn ? '按本次退回货物安排计划托数。' : `按柜级预览至少 ${inboundPlanMinimumPalletCount} 托,可增加,不能减少。` }}</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
|
||||
Reference in New Issue
Block a user