diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts
index e7dfd21d..acc914b2 100644
--- a/src/modules/inbounds/index.ts
+++ b/src/modules/inbounds/index.ts
@@ -20,3 +20,5 @@ export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
+
+export type { InboundOrderSummary, InboundPageResult } from './core/types'
diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
index f0efd047..e1ff7844 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
@@ -273,7 +273,7 @@ describe('InboundMeasurementPanel', () => {
expect(text).toContain('客户申报')
expect(text).toContain('平台实测')
expect(text).toContain('差异 +1.5 kg')
- expect(text).toContain('差异 -0.2 m³')
+ expect(text).toContain('差异 -0.200 m³')
expect(text).toContain('平台实测箱规')
expect(text).not.toContain('租户')
expect(text).not.toContain('数据版本')
@@ -297,7 +297,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
const rows = wrapper.findAll('.measurement-comparison tbody tr')
expect(rows.map(row => row.findAll('td').map(cell => cell.text()))).toEqual([
- ['10', '10', '一致'], ['118.5', '120', '差异 +1.5 kg'], ['0.8', '0.6', '差异 -0.2 m³'],
+ ['10', '10', '一致'], ['118.5', '120', '差异 +1.5 kg'], ['0.800', '0.600', '差异 -0.200 m³'],
])
expect(wrapper.find('.measurement-summary-section .measurement-declaration').exists()).toBe(false)
expect(wrapper.find('.measurement-declaration').element.parentElement).toBe(wrapper.find('.measurement-summary-section').element.parentElement)
@@ -319,8 +319,8 @@ describe('InboundMeasurementPanel', () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ measurementId: null, items: [], weightResults: [] }))
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-summary-header h4').text()).toBe('申报与实测对比')
- expect(wrapper.find('.measurement-declaration-header').text()).toContain('DOC-001')
- expect(wrapper.find('.measurement-section__heading .measurement-goods-name input').exists()).toBe(true)
+ expect(wrapper.find('.measurement-declaration-header').text()).not.toContain('DOC-001')
+ expect(wrapper.find('.measurement-section__heading .measurement-goods-name').exists()).toBe(false)
expect(wrapper.find('.measurement-section__heading .measurement-section-actions').text()).toContain('手动录入箱规')
expect(wrapper.findAll('.el-alert').some(alert => alert.text().includes('请先完成箱规测量'))).toBe(false)
expect(wrapper.find('.measurement-panel__footer').text()).toContain('请先完成箱规测量')
@@ -334,11 +334,11 @@ describe('InboundMeasurementPanel', () => {
expect(comparison.text()).toContain('118.5')
expect(comparison.text()).toContain('0.8')
expect(comparison.findAll('tbody tr')[0].text()).toContain('10')
- expect(declaration.text()).toContain('原单据号码')
- expect(declaration.text()).toContain('DOC-001')
- expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
+ expect(declaration.text()).not.toContain('原单据号码')
+ expect(declaration.text()).not.toContain('DOC-001')
+ expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(0)
expect(declaration.findAll('input')).toHaveLength(0)
- expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true)
+ expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
})
@@ -356,13 +356,14 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.find('input').exists()).toBe(false)
expect(declaration.text()).toContain('蓝牙音箱')
- expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
- expect(wrapper.find('.measurement-items-section .measurement-goods-name').text()).toContain('蓝牙音箱')
+ expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(0)
+ expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
})
- it('saves the corrected actual goods name without overriding declaration facts', async () => {
+ it('preserves the hidden actual goods name without overriding declaration facts', async () => {
const wrapper = await mountPanel()
- await wrapper.find('.measurement-goods-name input').setValue('修正后的蓝牙音箱')
+ expect(wrapper.text()).not.toContain('实际品名')
+ await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
@@ -370,7 +371,7 @@ describe('InboundMeasurementPanel', () => {
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
measurementId: '9001',
version: 2,
- actualGoodsName: '修正后的蓝牙音箱',
+ actualGoodsName: '蓝牙音箱',
items: expect.any(Array),
}))
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
@@ -573,7 +574,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-declaration input').exists()).toBe(false)
expect(wrapper.find('.measurement-items-section .measurement-expected-pallets input').exists()).toBe(true)
- expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true)
+ expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
})
it('requires at least one confirmed weight image before submitting for review', async () => {
diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
index f8a15ad9..980447f8 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
@@ -124,7 +124,7 @@ const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => su
const comparisonMetrics = computed(() => [
{ label: '箱数', declared: detail.value?.originalPackageCount, actual: form.items.length ? confirmedPackageCount.value : null, digits: 0, unit: '' },
{ label: '毛重(kg)', declared: detail.value?.originalGrossWeightKg, actual: detail.value?.actualGrossWeightKg, digits: 3, unit: ' kg' },
- { label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 4, unit: ' m³' },
+ { label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 3, unit: ' m³' },
])
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
@@ -557,9 +557,9 @@ async function submitForReview() {
}
}
-function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
+function formatNumber(value: number | null | undefined, maximumFractionDigits = 3, minimumFractionDigits = 0) {
if (value == null || !Number.isFinite(value)) return '—'
- return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
+ return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits }).format(value)
}
function deviationRatio(actual: number | null | undefined, declared: number | null | undefined) {
@@ -576,7 +576,7 @@ function formatDifference(actual: number | null | undefined, declared: number |
const difference = actual - declared
if (difference === 0) return null
const sign = difference > 0 ? '+' : ''
- return `差异 ${sign}${formatNumber(difference, maximumFractionDigits)}${unit}`
+ return `差异 ${sign}${formatNumber(difference, maximumFractionDigits, unit === ' m³' ? 3 : 0)}${unit}`
}
function releaseImagePreview() {
@@ -728,8 +728,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
| {{ metric.label }} |
- {{ formatNumber(metric.declared, metric.digits) }} |
- {{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits) }} |
+ {{ formatNumber(metric.declared, metric.digits, metric.unit === ' m³' ? 3 : 0) }} |
+ {{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits, metric.unit === ' m³' ? 3 : 0) }} |
{{ metric.actual == null ? '待测量' : metric.declared == null ? '—' : formatDifference(metric.actual, metric.declared, metric.digits, metric.unit) || '一致' }}
|
@@ -746,7 +746,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
平台实测箱规
-
-
-
实际品名{{ detail.actualGoodsName || '—' }}
+
@@ -800,7 +797,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
测量时间{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}
计算方式{{ calculationModeLabel(row.coefficientMode) }}
计算后外箱{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm
-
单箱体积{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³
+
单箱体积{{ formatNumber(row.serverValues.unitVolumeCbm, 3, 3) }} m³
箱规周长{{ formatNumber(row.serverValues.girthCm) }} cm
总材积重{{ formatNumber(row.serverValues.volumeWeightKg) }} kg
计算规则版本{{ row.calculationRuleVersion || '—' }}
@@ -848,7 +845,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
- {{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}
+ {{ formatNumber(row.serverValues.totalVolumeCbm, 3, 3) }}
diff --git a/src/modules/overseas-master-data/api/capacityOffers.ts b/src/modules/overseas-master-data/api/capacityOffers.ts
index 35e4d83f..670f0269 100644
--- a/src/modules/overseas-master-data/api/capacityOffers.ts
+++ b/src/modules/overseas-master-data/api/capacityOffers.ts
@@ -37,3 +37,7 @@ export async function commandInquiryFulfillment(bookingNo: string, action: 'inco
}
export async function saveAndConfirmInquiry(bookingNo: string, body: InquiryWrite) { return inquiryResult(await post(`${inquiryPath(bookingNo)}/save-and-confirm`, body)) }
+
+export async function listLooseboardDemands(query: { page: number; size: number; keyword?: string }) {
+ return page(await get>(`${base}/pending-looseboard-demands`, query), row => ({ ...row, version: number(row.version), estimatedPalletCount: nullable(row.estimatedPalletCount), packageCount: nullable(row.packageCount), totalWeightKg: nullable(row.totalWeightKg), totalVolumeCbm: nullable(row.totalVolumeCbm) }))
+}
diff --git a/src/modules/overseas-master-data/capacityOfferTypes.ts b/src/modules/overseas-master-data/capacityOfferTypes.ts
index ec2b7d0f..3d1d3970 100644
--- a/src/modules/overseas-master-data/capacityOfferTypes.ts
+++ b/src/modules/overseas-master-data/capacityOfferTypes.ts
@@ -31,6 +31,7 @@ export interface InquiryQuery { page?: number; size?: number; keyword?: string;
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
export interface InquiryWrite {
+ sourceOrderNo?: string; sourceOrderVersion?: number
tripRecordVersion: number; offerRecordVersion: number; recordVersion: number | null; customerId: string
businessType: CapacityInquiry['businessType']; deliveryMode: CapacityInquiry['deliveryMode']; estimatedArrivalAt: string | null
expectedPalletCount: number | null; cargo: TripBookingCargo; quoteRef: FleetQuoteReference | null; idempotencyKey: string
@@ -47,3 +48,9 @@ export const loadText = (value: CapacityLoad | null) => value ? `${value.palletS
export const appointmentCoverageLabels: Record = { SOURCE_NOT_READY: '待确认承运后筹划', SOURCE_NOT_FOUND: '来源待核对', SOURCE_NOT_MATERIALIZED: '待形成真实货件资料', DESTINATION_MISMATCH: '目的地变化待核对', REVIEW_REQUIRED: '货量待复核', UNASSIGNED: '尚未关联收货预约', PARTIAL: '部分货物未覆盖', PENDING: '等待收货方确认', CONFIRMED: '预约记录已确认' }
export const appointmentMissingLabels: Record = { CARRIER_CONFIRMATION: '承运确认', REAL_CARGO_MATERIALS: '真实货件资料', DESTINATION: '目的地核对', CARGO_QUANTITY_REVIEW: '货量复核', RECEIVING_APPOINTMENT: '收货预约', UNCOVERED_CARGO: '未覆盖货物的预约', RECEIVING_CONFIRMATION: '收货方确认', APPOINTMENT_TIMEZONE_UNVERIFIED: '预约时间所属地区尚未核实', SOURCE_CHANGED_REVIEW: '货源变化后重新核对预约' }
+
+export interface LooseboardDemand {
+ orderNo: string; customerId: string; version: number; origin: string; destination: string; productName: string | null
+ estimatedPalletCount: number | null; packageCount: number | null; totalWeightKg: number | null; totalVolumeCbm: number | null
+ currency: string; originContactName: string | null; originContactPhone: string | null; remark: string | null
+}
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
index 51b2cf4c..31e6ebcb 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
@@ -92,3 +92,21 @@ it('locks the destination in readonly follow-up even without offer detail, while
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
expect(saveCapacityInquiry).not.toHaveBeenCalled()
})
+
+it('matches an existing looseboard order without inventing space, arrival or agreed price and retries the same source', async () => {
+ permissions.values = new Set(['platform:fleet:capacity-offer:manage'])
+ const source = { orderNo: 'LB-ORIGINAL', customerId: '19', version: 4, origin: 'LA', destination: 'ONT8', productName: '家居用品', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2.5, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: '原单备注' }
+ vi.mocked(saveCapacityInquiry).mockRejectedValue(new Error('network'))
+ const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false, sourceDemand: source }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
+ await flushPromises()
+ expect(formItem(wrapper, '委托客户').findComponent({ name: 'ElSelect' }).props('disabled')).toBe(true)
+ expect(formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
+ expect(formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' }).props('disabled')).toBe(false)
+ await select(wrapper, '本车卸货站', 'FBA:ONT8')
+ const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
+ const body = vi.mocked(saveCapacityInquiry).mock.calls[0]![2]
+ expect(body).toMatchObject({ sourceOrderNo: 'LB-ORIGINAL', sourceOrderVersion: 4, customerId: '19', businessType: 'LOCAL_LOOSEBOARD', estimatedArrivalAt: null, expectedPalletCount: 3, cargo: { packageCount: 12, palletSpaces: null, unitPrice: null, weightKg: 100, volumeCbm: 2.5 } })
+ dialog.vm.$emit('confirm'); await flushPromises()
+ expect(saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, body)
+ expect(listOfferCustomers).not.toHaveBeenCalled()
+})
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
index b4a42da3..251e27f1 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
@@ -11,12 +11,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
-import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
+import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption, LooseboardDemand } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import { inquiryOrderTarget } from '../inquiryNavigation'
const open = defineModel({ default: false })
-const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
+const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean; sourceDemand?: LooseboardDemand | null }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref(null)
const followupActions = computed(() => {
@@ -68,13 +68,19 @@ watch(() => [open.value, target.value], async () => {
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
- if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
+ if (!row && props.sourceDemand) {
+ const source = props.sourceDemand
+ Object.assign(form, { customerId: source.customerId, businessType: 'LOCAL_LOOSEBOARD', expectedPalletCount: source.estimatedPalletCount })
+ Object.assign(form.cargo, { cargoName: source.productName, packageCount: source.packageCount, weightKg: source.totalWeightKg, volumeCbm: source.totalVolumeCbm, currency: source.currency, contactName: source.originContactName, contactPhone: source.originContactPhone, remark: source.remark })
+ customers.value = [{ id: source.customerId, code: source.customerId, name: `原单客户 ${source.customerId}` }]
+ }
+ if (!row && !props.sourceDemand && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
}, { immediate: true })
function searchCustomers(keyword = '') {
- if (lock.value || props.inquiry) return
+ if (lock.value || props.inquiry || props.sourceDemand) return
clearTimeout(timer); const current = ++customerSequence
timer = setTimeout(async () => {
customerLoading.value = true; customerError.value = null
@@ -98,7 +104,7 @@ async function save() {
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
- pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
+ pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { ...(props.sourceDemand && !props.inquiry ? { sourceOrderNo: props.sourceDemand.orderNo, sourceOrderVersion: props.sourceDemand.version } : {}), tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
@@ -109,7 +115,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
- 核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。
+ 散板原单:{{ sourceDemand.orderNo }} · {{ sourceDemand.origin }} → {{ sourceDemand.destination }}。请核对卸货站、标准托位和预计到仓时间。
+ 核对本票货量、价格与交货资料后确认承运。成功将保留货位并关联本土订单,不自动安排仓库收货。
本目的站可接余位
{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³
@@ -130,8 +137,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
- 委托客户先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。
暂无可选客户
-
+ 委托客户先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。
暂无可选客户
+
{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}
确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}
-
+
diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts
index 30f63890..bc159f04 100644
--- a/src/modules/overseas-master-data/index.ts
+++ b/src/modules/overseas-master-data/index.ts
@@ -14,3 +14,7 @@ export type { TripPreviousDispatch } from './tripTypes'
export { listVehicles } from './api/overseasMasterData'
export { listDrivers } from './api/drivers'
export type { DriverDetail } from './driverTypes'
+export { normalizeTripDetail } from './api/trips'
+export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
+export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
+export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
index f8d575e1..c9a4a81b 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
+++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
@@ -15,7 +15,7 @@ const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, link
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write) }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
-vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
+vi.mock('../api/capacityOffers', () => ({ listLooseboardDemands: vi.fn(), listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
const pageOf =
(records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) })
@@ -369,3 +369,18 @@ it('fetches an off-page returned inquiry once per refresh and retries without fa
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
+
+it('shows customer looseboard demand in the same pool and passes the original to the selected trip inquiry', async () => {
+ const source = { orderNo: 'LB-SOURCE', customerId: '19', version: 2, origin: 'LA', destination: 'ONT8', productName: '货物', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: null }
+ vi.mocked(api.listLooseboardDemands).mockResolvedValue(pageOf([source]))
+ const { wrapper } = mount(CapacityOffersView); await flushPromises()
+ await button(wrapper, '待匹配散板').trigger('click'); await flushPromises()
+ expect(api.listLooseboardDemands).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined })
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([source])
+ expect(api.getCapacityOffer).not.toHaveBeenCalled()
+ // 用页面实际暴露的匹配动作选择来源,再沿已有登记入口选车。
+ const vm = wrapper.vm as unknown as { matchDemand: (row: typeof source) => void; create: (row: CapacityOffer) => Promise }
+ vm.matchDemand(source); await flushPromises(); await vm.create(offer); await flushPromises()
+ expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('sourceDemand')).toEqual(source)
+ expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('offer').offerNo).toBe(offer.offerNo)
+})
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersView.vue b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
index 66e0b39b..c3feee59 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersView.vue
+++ b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
@@ -14,18 +14,21 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
-import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
-import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
+import { listLooseboardDemands, listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
+import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type LooseboardDemand, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore()
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
-const tab = ref<'offers' | 'inquiries'>(manage.value ? 'inquiries' : 'offers')
+const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
const filters = ref(manage.value ? { scope: 'TODO' } : {}), applied = ref({ ...filters.value })
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref(null)
const offers = ref([]), inquiries = ref([])
const editorOpen = ref(false), editing = ref(null), selectedOffer = ref(null), readonly = ref(true)
const confirmMode = ref(false)
+const demands = ref([]), selectedDemand = ref(null)
+const demandColumns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 130, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', label: '散板原单', minWidth: 220 }, { key: 'customerId', label: '客户编号', minWidth: 140 }, { key: 'origin', label: '起运地', minWidth: 170 }, { key: 'destination', label: '目的地', minWidth: 170 }, { key: 'productName', label: '货物', minWidth: 160 }, { key: 'estimatedPalletCount', label: '预计实托', width: 110 }, { key: 'packageCount', label: '箱数', width: 100 }]
+function matchDemand(row: LooseboardDemand) { selectedDemand.value = row; switchTab('offers') }
const fulfillmentOpen = ref(false), fulfillmentRow = ref(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
@@ -55,7 +58,7 @@ const fields = computed(() => tab.value === 'offers' ? [
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
-] : [
+] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的登记', options: [{ value: 'MINE', label: '我的登记' }, { value: 'ALL', label: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
@@ -74,12 +77,14 @@ async function create(row: CapacityOffer) {
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
}
-function switchTab(value: 'offers' | 'inquiries') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
+function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const q = applied.value
- if (tab.value === 'offers') {
+ if (tab.value === 'demands') {
+ const value = await listLooseboardDemands({ page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined }); if (current === sequence) { demands.value = value.records; total.value = value.total }
+ } else if (tab.value === 'offers') {
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
} else {
@@ -91,6 +96,7 @@ async function load() {
}
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
function accept(value: InquiryResult) {
+ if (selectedDemand.value && value.inquiry.businessOrderNo === selectedDemand.value.orderNo) { selectedDemand.value = null; switchTab('inquiries') }
const index = inquiries.value.findIndex(row => row.bookingNo === value.inquiry.bookingNo)
if (index >= 0) inquiries.value[index] = value.inquiry
for (const row of inquiries.value) if (row.offerNo === value.offer.offerNo) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
@@ -122,8 +128,15 @@ async function recheckEditor() {
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
-
- 待我处理选择车次发布余位
+ 正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。取消选择
+
+
+
+
+ 匹配车次
+
+
+ 待我处理选择车次发布余位
@@ -138,8 +151,8 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}{{ row.blockReason }}
{{ row.contactName || '—' }}
{{ row.contactPhone || '—' }}
-
- {{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}
+
+ {{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}
@@ -148,5 +161,5 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
重试原操作
-
+
diff --git a/src/modules/overseas-master-data/tripTypes.ts b/src/modules/overseas-master-data/tripTypes.ts
index b34ed9d6..49310bbc 100644
--- a/src/modules/overseas-master-data/tripTypes.ts
+++ b/src/modules/overseas-master-data/tripTypes.ts
@@ -8,7 +8,7 @@ export interface TripStop { name: string; address?: string | null; destinationKe
export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
export interface FleetTrip {
- tripPurpose?: 'DELIVERY' | 'PICKUP'
+ tripPurpose?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'
tripNo: string
status: TripStatus
warehouseId: string
diff --git a/src/modules/overseas-warehouse/api/transportTasks.ts b/src/modules/overseas-warehouse/api/transportTasks.ts
new file mode 100644
index 00000000..950bdb1f
--- /dev/null
+++ b/src/modules/overseas-warehouse/api/transportTasks.ts
@@ -0,0 +1,26 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { normalizeTripDetail, type TripDetail, type PickupTask, type PickupCandidate, type PickupEvidence, type PickupPlan, type TripAssignment, type TripResourceRelease } from '@/modules/overseas-master-data'
+
+export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
+export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
+export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[] }
+export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
+export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
+type WireDetail = Omit
& { execution: Parameters[0] }
+const base = '/platform/v1/last-leg/dispatches/transport-tasks'
+const path = (number: string) => `${base}/${encodeURIComponent(number)}`
+async function get(url: string, params?: object) { return unwrapApiResponse(await http.get, ApiEnvelope>(url, { params })) }
+async function post(url: string, body: object) { return unwrapApiResponse(await http.post, ApiEnvelope>(url, body)) }
+const normalize = (value: WireDetail): TransportDetail => ({ ...value, execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
+export const getTransport = (number: string) => get(path(number)).then(normalize)
+export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
+export const truckCandidates = () => get(`${base}/truck-candidates`)
+export const createTransportPickup = (body: PickupPlan) => post(`${base}/pickup`, body).then(normalize)
+export const createTransportTruck = (body: TruckPlan) => post(`${base}/truck`, body).then(normalize)
+export const assignTransport = (number: string, body: TripAssignment) => post(`${path(number)}/assign`, body).then(normalize)
+export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post>(`${path(number)}/resource-occupancy`, { kind, resourceIds }) : Promise.resolve([])
+export const cancelTransport = (number: string, body: object) => post(`${path(number)}/cancel`, body)
+export const releaseTransport = (number: string, body: TripResourceRelease) => post(`${path(number)}/release`, body)
+export const commandTransport = (number: string, suffix: string, body: object) => post(`${path(number)}/${suffix}`, body)
+export async function uploadTransport(number: string, suffix: string, file: File) { const body = new FormData(); body.append('file', file); return post(`${path(number)}/${suffix}/files`, body) }
+export const downloadTransportEvidence = (number: string, fileId: string, row?: PickupTask): Promise => http.get(`${path(number)}/${row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
diff --git a/src/modules/overseas-warehouse/components/TransferQuantitySummary.test.ts b/src/modules/overseas-warehouse/components/TransferQuantitySummary.test.ts
deleted file mode 100644
index d61060bd..00000000
--- a/src/modules/overseas-warehouse/components/TransferQuantitySummary.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { mount, flushPromises } from '@vue/test-utils'
-import { describe, expect, it, vi } from 'vitest'
-import TransferQuantitySummary from './TransferQuantitySummary.vue'
-import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
-vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
-
-describe('转运完整筛选数量', () => {
- it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
- vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
- const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
- await flushPromises()
- expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
- expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
- expect(wrapper.text()).toContain('剩余 2 托')
- expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
- await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
- await flushPromises()
- expect(wrapper.text()).toContain('全筛选 2 票')
- expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
- })
- it('shows a recoverable error instead of zero totals', async () => {
- vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
- const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
- await flushPromises()
- expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
- expect(wrapper.text()).not.toContain('全筛选 0')
- })
-})
diff --git a/src/modules/overseas-warehouse/components/TransferQuantitySummary.vue b/src/modules/overseas-warehouse/components/TransferQuantitySummary.vue
deleted file mode 100644
index 2c76d233..00000000
--- a/src/modules/overseas-warehouse/components/TransferQuantitySummary.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
- 仓储转运 · 全筛选 {{ summary.orderCount }} 票 ·
- 预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
- 历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
- 当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
-
- {{ error }}
-
diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
new file mode 100644
index 00000000..abd8952a
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
@@ -0,0 +1,126 @@
+
+
+
+
+ 按原请求重试
+
+ {{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}
+ 本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}
+ 调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}
+ {{ index + 1 }}. {{ stop.name }} {{ stop.address }}
+
+
+ 加载车辆司机候选
+
+
+ 保存车辆司机
+
+
+ 确认发车
+ 取消排车
+ 确认运输完成
+ 结束车辆任务结束司机任务
+ 刷新运输进度
+
+ 送仓后由仓库独立清点收货,取货数量保留为运输事实。
+ {{ pickupStatusLabels[row.status] }}{{ file.fileName }}确认取货确认送仓
+
+ 已确认的运输完成凭证
{{ file.fileName }}
+
+ {{ selected?.transferNo }}
{{ file.fileName }}
确认实提
+ 确认任务结束并释放占用
+
+
diff --git a/src/modules/overseas-warehouse/components/TransportTaskCreateDialog.vue b/src/modules/overseas-warehouse/components/TransportTaskCreateDialog.vue
new file mode 100644
index 00000000..e1c019ee
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/TransportTaskCreateDialog.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ 审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。
+
+
+
+
+
+
+
+
+
+ 取消{{ pending ? '按原请求重试' : '生成排车单' }}
+
+
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts
index a3c27d53..1a077caf 100644
--- a/src/modules/overseas-warehouse/dispatchContracts.ts
+++ b/src/modules/overseas-warehouse/dispatchContracts.ts
@@ -24,13 +24,13 @@ export interface DispatchBatchAllocation { planPalletId: string; batchKey: strin
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
-export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
+export interface DispatchListRow { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
-export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
+export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts b/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts
index 83847504..b70e6cb1 100644
--- a/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts
+++ b/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts
@@ -41,7 +41,10 @@ async function retry(state: VueWrapper) { await state.get('.load-error-state__ac
describe('native cargo discard detail and approval', () => {
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
- const { dialog } = await open()
+ const { wrapper, dialog } = await open()
+ expect(api.statistics).not.toHaveBeenCalled()
+ expect(wrapper.text()).not.toContain('全筛选')
+ expect(wrapper.text()).not.toContain('统计未加载')
expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994')
const facts = dialog.get('[aria-label="废弃单基础资料"]')
for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value)
diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue
index c9e2b886..588c2ce8 100644
--- a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue
+++ b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue
@@ -13,18 +13,17 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
-import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
+import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref([]); const customers = ref([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref(null); const filters = ref({ customerId: '', discardNo: '', inboundCode: '', status: '' })
-const statistics = ref(null)
/** 数量事实缺失时明确提示,不用零代替未记录。 */
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
-const customerError = ref(null); const listError = ref(null); const statisticsError = ref(null)
+const customerError = ref(null); const listError = ref(null)
const detailError = ref(null); const reviewError = ref(null)
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref(null)
const photoUrls = reactive>({}); const photoLoading = reactive>({}); const photoErrors = reactive>({})
@@ -43,17 +42,20 @@ async function loadPhoto(fileId: string) {
watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } })
onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() })
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
-/** 分页与全筛选统计独立保留诊断,过期响应不得覆盖新的查询。 */
+/** 过期响应不得覆盖新的查询。 */
async function load(model: QueryModel = filters.value) {
- const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null; statisticsError.value = null; statistics.value = null
+ const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null
const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }
- const [list, stat] = await Promise.allSettled([listCargoDiscards(query), getCargoDiscardStatistics(query)])
- if (disposed || generation !== loadGeneration) return
- if (list.status === 'fulfilled') { rows.value = list.value.records; total.value = list.value.total }
- else { rows.value = []; total.value = 0; listError.value = getApiErrorDetails(list.reason, '废弃单列表加载失败') }
- if (stat.status === 'fulfilled') statistics.value = stat.value
- else statisticsError.value = getApiErrorDetails(stat.reason, '废弃统计加载失败')
- loading.value = false
+ try {
+ const result = await listCargoDiscards(query)
+ if (disposed || generation !== loadGeneration) return
+ rows.value = result.records; total.value = result.total
+ } catch (error) {
+ if (disposed || generation !== loadGeneration) return
+ rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
+ } finally {
+ if (!disposed && generation === loadGeneration) loading.value = false
+ }
}
async function loadCustomers() {
customerError.value = null
@@ -78,7 +80,7 @@ async function audit(row: CargoDiscardSummary) {
onMounted(() => { void loadCustomers(); void load() })
-废弃单
详情审核并推送 PDA全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}统计未加载
+
正在读取废弃单…
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
index 09f3121e..82ebc326 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
@@ -16,9 +16,16 @@ vi.mock('@/modules/overseas-master-data', async () => ({
vehicleStatusLabels: (await vi.importActual('../../overseas-master-data/presentation')).vehicleStatusLabels,
inquiryReturnTarget: (value: Record) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
}))
-const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
+const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
+ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('hosts %s execution in the existing dispatch page', async taskType => {
+ vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
+ const wrapper = mount(DispatchDetailView, options); await flushPromises()
+ expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
+ expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
+ expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
+ })
it('returns to the original appointment date and view from the dispatch detail', async () => {
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
const wrapper = mount(DispatchDetailView, options); await flushPromises()
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
index 9ee8fe27..a5469a3b 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
@@ -14,6 +14,7 @@ import { getDispatch } from '../api/dispatches'
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
+import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
@@ -82,8 +83,9 @@ onBeforeUnmount(() => { generation++ })
-
{{ dispatchActionLabels[value] }}调整卸货顺序
-
+
+ {{ dispatchActionLabels[value] }}调整卸货顺序
+
🚚车辆信息- {{ field[0] }}
- {{ field[1] }}
📋排车信息- {{ field[0] }}
- {{ field[1] }}
📝单据信息
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
index e12123b2..1422af26 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
@@ -18,7 +18,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '' }, routeLocation: (label: string) => ({ label }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
-const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true } } }
+const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
@@ -31,7 +31,7 @@ it('offers recovery of the original pending command independently of the filtere
it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
- expect(wrapper.get('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
+ expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
await tabs[3]!.trigger('click'); await flushPromises()
@@ -67,6 +67,15 @@ it('navigates by the business number without arranging a separate trip', async (
const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } }); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
})
+it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispatch detail and omits warehouse commands', async taskType => {
+ vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
+ const wrapper = mount(DispatchesView, options); await flushPromises()
+ const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
+ expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'detail', label: '运输操作' })])
+ row.vm.$emit('action', 'detail'); await flushPromises()
+ expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
+ expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
+})
it('reads one BOL on demand and preserves popup failure handling', async () => {
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue
index 4e970824..b0369c4e 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue
@@ -21,6 +21,8 @@ import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatche
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
+import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
+import { transportLabels, type TransportType } from '../api/transportTasks'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
@@ -30,6 +32,8 @@ const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.us
const rows = ref([]), statistics = ref(null)
const loading = ref(false), loadError = ref(null), mutationError = ref(null)
const rejectionError = ref(null)
+const transportOpen = ref(false), transportType = ref('PICKUP')
+function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -48,6 +52,7 @@ const fields = computed(() => [
])
const columns = computed(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
+ { key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
@@ -75,6 +80,7 @@ function switchSection(value: BeforeLoadingSection) { section.value = value; mod
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
function rowActions(row: DispatchListRow): RowActionItem[] {
+ if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
}
function handleAction(value: string, row: DispatchListRow) {
@@ -118,15 +124,17 @@ onBeforeUnmount(() => { sequence++ })
{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}
返回预约工作
- 新建排车
+ 安排到仓取货安排本土卡车新建排车
操作资格未返回,请刷新核对
+ {{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}
{{ row.resources.vehicle?.plate || '—' }}{{ row.resources.driver?.name || '—' }}
{{ row.resources.driver?.phone || '—' }}
{{ reviewLabels[review.status as ReviewStatus] }}
{{ review.reason }}
{{ review.invalidatedReason }}
{{ time(review.reviewedAt) }}
{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱{{ time(row.plannedDepartureAt) }}{{ time(row.departedAt) }}{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}查看路线
+
diff --git a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts
index 77f18653..5330dd45 100644
--- a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts
+++ b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts
@@ -89,7 +89,8 @@ describe('inventory customer-name snapshots', () => {
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
- expect(wrapper.text()).toContain('可用 12 件 / 2 箱 / 1 托')
+ expect(wrapper.find('[aria-label="可用库存件箱托汇总"]').exists()).toBe(false)
+ expect(wrapper.get('[aria-label="库存汇总"]').text()).toContain('可用库存(箱)10')
})
it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => {
diff --git a/src/modules/overseas-warehouse/pages/InventoryView.vue b/src/modules/overseas-warehouse/pages/InventoryView.vue
index 56fdea1e..36c98ed8 100644
--- a/src/modules/overseas-warehouse/pages/InventoryView.vue
+++ b/src/modules/overseas-warehouse/pages/InventoryView.vue
@@ -13,7 +13,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
-import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceProjection, type InventoryPieceGroup } from '../api/inventory'
+import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceGroup } from '../api/inventory'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
@@ -26,7 +26,6 @@ const access = useAccessPreviewStore()
const warehouses = ref([])
const rows = ref([])
const summary = ref(null)
-const pieces = ref(null)
const rowPieces = ref>({})
const pieceError = ref('')
const loading = ref(false)
@@ -80,7 +79,7 @@ function query() {
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
- loading.value = true; loadError.value = null; pieces.value = null; rowPieces.value = {}; pieceError.value = ''
+ loading.value = true; loadError.value = null; rowPieces.value = {}; pieceError.value = ''
try {
const currentQuery = query()
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
@@ -88,10 +87,9 @@ async function load(model: QueryModel = filters.value) {
try {
const pieceResult = await getInventoryPieceProjection(currentQuery)
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
- pieces.value = pieceResult
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
}
- catch (error) { rows.value = []; total.value = 0; summary.value = null; pieces.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
+ catch (error) { rows.value = []; total.value = 0; summary.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
finally { loading.value = false }
}
@@ -110,7 +108,7 @@ onMounted(() => { void loadWarehouses(); void load() })
库存管理
{{ pieceError }}
- {{ metric[0] }}(箱){{ metric[1] }}
全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托
+ {{ metric[0] }}(箱){{ metric[1] }}
导出库存进销存记录
查看
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
index a0eaffdb..6f1b4e9b 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
+++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
@@ -211,7 +211,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
table.vm.$emit('update:page', 3)
await flushPromises()
- expect(summary().exists()).toBe(false)
+ expect(wrapper.find('[aria-label="退货全筛选汇总"]').exists()).toBe(false)
expect(wrapper.text()).toContain('尾程订单加载失败')
expect(table.props('data')).toEqual([])
})
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts
index acb1cd10..14bfea5b 100644
--- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts
+++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts
@@ -46,23 +46,16 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('RelabelOrdersView', () => {
- it('uses full-filter totals instead of the visible page and preserves unknown boxes', async () => {
+ it('hides filter totals and preserves row quantities and unknown boxes', async () => {
vi.mocked(listRelabelOrders).mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-PIECE', skuCodes: 'A', totalPackageCount: 100, status: 'PROCESSING' }], page: 1, size: 10, total: 45, pages: 5 })
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 45, skuCount: 12, knownLabelPieceCount: 300, unknownPieceOrders: 1, knownCartonCount: 8, unknownCartonOrders: 2, orders: { 'RL-PIECE': { labelPieceCount: 100, cartonCount: null, skuCount: 1, duplicateCarton: false, quantityUnit: 'PIECE', sourcePalletCount: null, targetPalletCount: null } } })
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
await flushPromises()
- const summary = wrapper.get('[aria-label="全部筛选结果数量汇总"]')
- expect(summary.text()).toContain('45 票')
- expect(summary.text()).toContain('300 件')
- expect(summary.text()).toContain('8 箱')
- expect(wrapper.findAll('tbody tr').some(row => row.text().includes('RL-PIECE') && row.text().includes('未提供') && row.text().includes('100'))).toBe(true)
- })
- it('does not display all-unknown carton quantities as zero boxes', async () => {
- vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 1, knownLabelPieceCount: 100, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 1, orders: {} })
- const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
- await flushPromises()
- expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).toContain('箱数未提供(1票)')
- expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).not.toContain('0 箱')
+ expect(wrapper.find('[aria-label="全部筛选结果数量汇总"]').exists()).toBe(false)
+ const cells = wrapper.findAll('tbody tr').find(row => row.text().includes('RL-PIECE'))!.findAll('td')
+ expect(cells[5]!.text()).toBe('—')
+ expect(cells[5]!.text()).toBe(cells[8]!.text())
+ expect(cells[6]!.text()).toBe('100')
})
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 2, knownLabelPieceCount: 15, unknownPieceOrders: 0, knownCartonCount: 1, unknownCartonOrders: 0, orders: { 'RL-MULTI': { labelPieceCount: 15, cartonCount: 1, skuCount: 2, duplicateCarton: false, quantityUnit: 'CARTON', sourcePalletCount: null, targetPalletCount: null } } })
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue
index 2af3e705..e55b3e2f 100644
--- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue
+++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue
@@ -38,9 +38,7 @@ const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const quantities = ref(null)
-const quantityError = ref('')
-function dimension(value: number | string | null | undefined) { return value == null ? '未提供' : String(value) }
-function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : unit === '托' ? '托盘数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
+function dimension(value: number | string | null | undefined) { return value == null ? '—' : String(value) }
const filters = ref({ relabelNo: '', sku: '', status: '' })
const fields = computed(() => [
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
@@ -69,7 +67,7 @@ const dialogMode = ref<'create' | 'edit'>('create')
const submitting = ref(false)
const detailLoading = ref(false)
const editSourceStatus = ref('')
-const auditLabelPieces = ref('未提供')
+const auditLabelPieces = ref('—')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createItems = ref([])
const createOperation = ref('CARTON_LABEL')
@@ -254,12 +252,11 @@ async function load(model: QueryModel = filters.value) {
loading.value = true; loadError.value = null
try {
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
- quantities.value = null; quantityError.value = ''
+ quantities.value = null
const result = await listRelabelOrders(query)
const measured = await getRelabelQuantities(query).catch(() => null)
if (current !== loadGeneration) return
quantities.value = measured
- if (!measured) quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计'
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
@@ -281,7 +278,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
- 全部筛选结果{{ quantities.orderCount }} 票{{ quantities.skuCount }} 种 SKU{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}{{ quantityError }}
换标单
@@ -327,7 +323,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })