feat(admin): adapt channel booking and smart loading contracts
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createOceanBooking, listOceanBookings, performOceanBookingAction, requestOceanBookingChange } from './platformOceanBookings'
|
||||
import { createOceanBooking, listOceanBookings, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
const detailResponse = (version = 0) => ({
|
||||
@@ -22,7 +22,7 @@ describe('ocean bookings api', () => {
|
||||
|
||||
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: '456', customerName: '客户', parties: [], containerRequirements: [] } })
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: '456', customerName: '客户', channelCode: 'SEA', channelName: '海运', warehouseId: '8', warehouseName: '义乌仓', parties: [], containerRequirements: [] } })
|
||||
expect(post.mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 0 })
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(created.id).toBe('123')
|
||||
@@ -38,5 +38,9 @@ describe('ocean bookings api', () => {
|
||||
await requestOceanBookingChange('123', { expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
|
||||
expect(post.mock.calls[1]?.[0]).toContain('/123/change-request')
|
||||
expect(post.mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
|
||||
|
||||
await releaseOceanBooking('123', { expectedVersion: 4, billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
|
||||
expect(post.mock.calls[2]?.[0]).toContain('/123/release')
|
||||
expect(post.mock.calls[2]?.[1]).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
|
||||
import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/ocean-bookings'
|
||||
function pageNumber(value: number | string, fallback: number) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback }
|
||||
@@ -45,7 +45,8 @@ function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().
|
||||
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export type OceanBookingAction = 'submit' | 'confirm' | 'release' | 'fail' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
|
||||
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request'>, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request' | 'release'>, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function requestOceanBookingChange(id: string, request: OceanBookingChangeRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/change-request`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
|
||||
export type { OceanBookingResource, OceanBookingStatus }
|
||||
|
||||
@@ -41,6 +41,15 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
]) expect(source).toContain(`placeholder="${placeholder}"`)
|
||||
})
|
||||
|
||||
it('captures channel, warehouse, capacity snapshots and uses the release DTO', () => {
|
||||
expect(source).toContain('v-model="form.data.channelCode"')
|
||||
expect(source).toContain('v-model="form.data.channelName"')
|
||||
expect(source).toContain('form.data.warehouseName = warehouses.find')
|
||||
expect(source).toContain('v-model="container.capacityCbm"')
|
||||
expect(source).toContain('v-model="container.maxWeightKg"')
|
||||
expect(source).toContain('releaseOceanBooking(id.value, releaseRequest)')
|
||||
})
|
||||
|
||||
it('keeps view, create and edit on the formal detail route with a return action', () => {
|
||||
expect(source).toContain("router.push('/ocean-bookings')")
|
||||
expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)")
|
||||
|
||||
@@ -14,10 +14,12 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import {
|
||||
createOceanBooking,
|
||||
getOceanBooking,
|
||||
performOceanBookingAction,
|
||||
releaseOceanBooking,
|
||||
requestOceanBookingChange,
|
||||
updateOceanBooking,
|
||||
type OceanBookingAction,
|
||||
@@ -68,6 +70,7 @@ const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const partners = ref<PlatformPartnerResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const optionsError = ref('')
|
||||
const selectableCustomers = computed(() => customers.value.filter((customer) => (
|
||||
@@ -81,8 +84,8 @@ function blankParties(): OceanBookingParty[] {
|
||||
}
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
customerId: '', customerName: '', parties: blankParties(),
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }],
|
||||
customerId: '', customerName: '', channelCode: '', channelName: '', warehouseId: '', warehouseName: '', parties: blankParties(),
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
|
||||
}
|
||||
}
|
||||
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
|
||||
@@ -106,12 +109,14 @@ async function loadOptions() {
|
||||
optionsLoading.value = true
|
||||
optionsError.value = ''
|
||||
try {
|
||||
const [customerResult, partnerResult] = await Promise.all([
|
||||
const [customerResult, partnerResult, warehouseResult] = await Promise.all([
|
||||
listPlatformTenants({ page: 1, size: 200 }),
|
||||
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
listPlatformWarehouses({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
])
|
||||
customers.value = customerResult.records
|
||||
partners.value = partnerResult.records
|
||||
warehouses.value = warehouseResult.records
|
||||
} catch (cause) {
|
||||
optionsError.value = getApiErrorDetails(cause, '客户及合作单位选项加载失败').message
|
||||
} finally {
|
||||
@@ -136,7 +141,7 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
detail.value = value
|
||||
form.expectedVersion = value.version
|
||||
form.data = {
|
||||
customerId: value.customerId || '', customerName: value.customerName || '', billOfLadingNo: value.billOfLadingNo,
|
||||
customerId: value.customerId || '', customerName: value.customerName || '', channelCode: value.channelCode || '', channelName: value.channelName || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '', billOfLadingNo: value.billOfLadingNo,
|
||||
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
||||
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
|
||||
vesselName: value.vesselName, voyageNo: value.voyageNo,
|
||||
@@ -146,7 +151,7 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
estimatedSailingAt: value.estimatedSailingAt, estimatedArrivalAt: value.estimatedArrivalAt,
|
||||
remark: value.remark,
|
||||
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
||||
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, quantity }) => ({ containerType, containerKind, quantity })) : [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }],
|
||||
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })) : [{ containerType: '40HC', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
@@ -213,13 +218,16 @@ async function runAction(action: OceanBookingAction) {
|
||||
const reason = await requiredReason(action)
|
||||
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
actionSaving.value = true
|
||||
applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
|
||||
if (action === 'release') {
|
||||
const releaseRequest = { expectedVersion: detail.value.version, billOfLadingNo: form.data.billOfLadingNo || '', vesselName: form.data.vesselName || '', voyageNo: form.data.voyageNo || '', estimatedSailingAt: form.data.estimatedSailingAt || '', estimatedArrivalAt: form.data.estimatedArrivalAt || '', reason }
|
||||
applyDetail(await releaseOceanBooking(id.value, releaseRequest))
|
||||
} else applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
|
||||
ElMessage.success(`${actionLabels[action]}成功`)
|
||||
} catch (cause) {
|
||||
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
|
||||
} finally { actionSaving.value = false }
|
||||
}
|
||||
function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', quantity: 1 }) }
|
||||
function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }) }
|
||||
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
|
||||
|
||||
onMounted(() => { void load(); void loadOptions() })
|
||||
@@ -255,6 +263,9 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
||||
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
|
||||
<el-form-item label="渠道编码" prop="channelCode"><el-input v-model="form.data.channelCode" placeholder="请输入渠道编码" /></el-form-item>
|
||||
<el-form-item label="渠道名称" prop="channelName"><el-input v-model="form.data.channelName" placeholder="请输入渠道名称" /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" style="width:100%" @update:model-value="(value) => { form.data.warehouseId = String(value || ''); form.data.warehouseName = warehouses.find((item) => String(item.id) === String(value))?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '当前船公司'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '当前订舱代理'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
@@ -280,6 +291,8 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
|
||||
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
|
||||
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
|
||||
<el-input-number v-model="container.capacityCbm" :disabled="!editable" :min="0.01" :precision="2" placeholder="体积 m³" />
|
||||
<el-input-number v-model="container.maxWeightKg" :disabled="!editable" :min="0.01" :precision="2" placeholder="限重 kg" />
|
||||
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
|
||||
<el-button v-if="editable" type="danger" plain :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="15" />删除</el-button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
|
||||
|
||||
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
|
||||
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number; containerKind?: string | null }
|
||||
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number; containerKind?: string | null; capacityCbm: number; maxWeightKg: number }
|
||||
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
|
||||
export interface OceanBookingDraftData {
|
||||
customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
|
||||
channelCode: string; channelName: string; warehouseId: string; warehouseName: string
|
||||
billOfLadingNo?: string | null
|
||||
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
|
||||
departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null
|
||||
@@ -14,6 +15,7 @@ export interface OceanBookingDraftData {
|
||||
export interface OceanBookingResource {
|
||||
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
||||
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
|
||||
channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null
|
||||
shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null
|
||||
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null
|
||||
destinationPortCode: string | null; destinationPortName: string | null; placeOfDelivery: string | null
|
||||
@@ -32,6 +34,7 @@ export interface OceanBookingPage { records: OceanBookingResource[]; page: numbe
|
||||
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
|
||||
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
|
||||
export interface OceanBookingActionRequest { expectedVersion: number; reason?: string | null }
|
||||
export interface OceanBookingReleaseRequest { expectedVersion: number; billOfLadingNo: string; vesselName: string; voyageNo: string; estimatedSailingAt: string; estimatedArrivalAt: string; reason?: string | null }
|
||||
export interface OceanBookingChangeRequest extends OceanBookingActionRequest {
|
||||
vesselName: string
|
||||
voyageNo: string
|
||||
|
||||
Reference in New Issue
Block a user