优化
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
|
||||
|
||||
Reference in New Issue
Block a user