diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts index 684abc46..bbabd03d 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -1,5 +1,42 @@ import { describe, expect, it, vi } from 'vitest' -import { listOceanBookings, performOceanBookingAction, createOceanBooking } from './platformOceanBookings' +import { createOceanBooking, listOceanBookings, performOceanBookingAction, requestOceanBookingChange } from './platformOceanBookings' import { http } from '@/shared/api/http' -describe('ocean bookings api', () => { it('normalizes pagination and long ids', async () => { vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } }); const result = await listOceanBookings({ page: 1, size: 10 }); expect(result.page).toBe(2); expect(result.total).toBe(21); expect(result.records[0]?.id).toBe('123'); expect(result.records[0]?.customerId).toBe('456') }) - it('wraps writes with idempotency and expected version action payload', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: 'id-1' }); await createOceanBooking({ expectedVersion: null, data: { customerId: 'c1', customerName: '客户', parties: [], containerRequirements: [] } }); expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); await performOceanBookingAction('id-1', 'confirm', { expectedVersion: 3, reason: 'ok' }); expect(post.mock.calls[1]?.[0]).toContain('/id-1/confirm'); expect(post.mock.calls[1]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' }) }) }) + +const detailResponse = (version = 0) => ({ + oceanBooking: { + id: 123, bookingNo: 'OB123', status: 'DRAFT', version, customerId: 456, customerName: '客户', + containers: [], + }, + parties: [], containerRequirements: [], containerAllocations: [], histories: [], +}) + +describe('ocean bookings api', () => { + it('normalizes pagination and long ids', async () => { + vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } }) + const result = await listOceanBookings({ page: 1, size: 10 }) + expect(result.page).toBe(2) + expect(result.total).toBe(21) + expect(result.records[0]?.id).toBe('123') + expect(result.records[0]?.customerId).toBe('456') + }) + + 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: [] } }) + 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') + expect(created.version).toBe(0) + }) + + it('uses action and change-specific request contracts', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) }) + await performOceanBookingAction('123', 'confirm', { expectedVersion: 3, reason: 'ok' }) + expect(post.mock.calls[0]?.[0]).toContain('/123/confirm') + expect(post.mock.calls[0]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' }) + + 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: '调整船期' }) + }) +}) diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.ts b/src/modules/ocean-bookings/api/platformOceanBookings.ts index 6feb3883..4d9f5200 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.ts @@ -1,20 +1,51 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types' +import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } 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 } -function booking(raw: OceanBookingResource): OceanBookingResource { const item = raw as OceanBookingResource & { departurePortName?: string | null; destinationPortName?: string | null; shippingCompanyName?: string | null; estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null }; return { ...raw, id: String(raw.id), customerId: raw.customerId == null ? null : String(raw.customerId), pol: raw.pol ?? item.departurePortName ?? null, pod: raw.pod ?? item.destinationPortName ?? null, carrierName: raw.carrierName ?? item.shippingCompanyName ?? null, etd: raw.etd ?? item.estimatedSailingAt ?? null, eta: raw.eta ?? item.estimatedArrivalAt ?? null, containers: (raw.containers || []).map((container) => ({ ...container, id: String(container.id ?? '') })) } } +function booking(raw: OceanBookingResource): OceanBookingResource { + const item = raw as OceanBookingResource & { version?: number | string | null } + return { + ...raw, + id: String(raw.id), + version: Number(item.version ?? 0), + customerId: raw.customerId == null ? null : String(raw.customerId), + shippingCompanyId: raw.shippingCompanyId == null ? null : String(raw.shippingCompanyId), + bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId), + pol: raw.pol ?? raw.departurePortName ?? null, + pod: raw.pod ?? raw.destinationPortName ?? null, + carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null, + bookingAgent: raw.bookingAgent ?? raw.bookingAgentName ?? null, + etd: raw.etd ?? raw.estimatedSailingAt ?? null, + eta: raw.eta ?? raw.estimatedArrivalAt ?? null, + containers: (raw.containers || []).map((container) => ({ ...container, id: String(container.id ?? '') })), + } +} +function detail(raw: OceanBookingDetail): OceanBookingDetail { + const source = booking(raw.oceanBooking || raw) + const histories = (raw.histories || raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) })) + return { + ...raw, + ...source, + oceanBooking: source, + parties: (raw.parties || []).map((party) => ({ ...party, id: party.id == null ? undefined : String(party.id) })), + containerRequirements: (raw.containerRequirements || []).map((container) => ({ ...container, id: container.id == null ? undefined : String(container.id) })), + containerAllocations: raw.containerAllocations || [], + histories, + statusHistory: histories, + } +} export async function listOceanBookings(query: OceanBookingQuery) { const response = await http.get, ApiEnvelope>(basePath, { params: query }) const result = unwrapApiResponse(response) return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage } -export async function getOceanBooking(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`); const raw = unwrapApiResponse(response); const source = raw.oceanBooking || raw; const normalized = booking(source); const histories = (raw.histories || raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) })); return { ...raw, ...normalized, oceanBooking: normalized, parties: raw.parties || [], containerRequirements: raw.containerRequirements || [], containerAllocations: raw.containerAllocations || [], histories, statusHistory: histories } } +export async function getOceanBooking(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) } function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` } -export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) } -export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) } +export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) } +export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put, ApiEnvelope>(`${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 interface OceanBookingActionRequest { expectedVersion: number | null; reason?: string | null } -export async function performOceanBookingAction(id: string, action: OceanBookingAction, request: OceanBookingActionRequest) { const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) } +export async function performOceanBookingAction(id: string, action: Exclude, request: OceanBookingActionRequest) { const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) } +export async function requestOceanBookingChange(id: string, request: OceanBookingChangeRequest) { const response = await http.post, ApiEnvelope>(`${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(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) } export type { OceanBookingResource, OceanBookingStatus } diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index 53170334..6e88021d 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -4,19 +4,30 @@ import { describe, expect, it } from 'vitest' const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8') -describe('OceanBookingDetailView form contract', () => { - it('keeps prototype parties, sailing and cargo fields', () => { - for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements', 'goodsDescription']) { +describe('OceanBookingDetailView contract', () => { + it('uses the backend draft fields and all required parties', () => { + for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'partyName', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements']) { expect(source).toContain(field) } + expect(source).not.toContain('goodsDescription') + expect(source).not.toContain('form.data.soNo') }) - it('validates required booking fields and protects edit-only fields', () => { - expect(source).toContain("customerId: [{ required: true") - expect(source).toContain("departurePortName: [{ required: true") - expect(source).toContain("bookingAgentName: [{ required: true") + it('reads the real version and protects create/edit permissions', () => { + expect(source).toContain('form.expectedVersion = value.version') + expect(source).toContain('expectedVersion: 0') + expect(source).toContain("platform:firstleg:ocean-booking:create") + expect(source).toContain("platform:firstleg:ocean-booking:edit") expect(source).toContain(':disabled="!isNew"') expect(source).toContain(':disabled="!editable"') - expect(source).toContain("containerType: '40HC', containerKind: 'FCL', quantity: 1") + }) + + it('maps each status action to its server permission and uses a change-specific request', () => { + for (const permission of ['submit', 'confirm', 'release', 'cancel', 'change']) { + expect(source).toContain(`platform:firstleg:ocean-booking:${permission}`) + } + expect(source).toContain('requestOceanBookingChange') + expect(source).toContain("changeForm.vesselName = detail.value?.vesselName") + expect(source).toContain('expectedVersion: detail.value.version') }) }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index 2dc4f548..55685298 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -1,37 +1,271 @@ + - + + diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.vue b/src/modules/ocean-bookings/pages/OceanBookingsView.vue index cc4c803b..17fd63a5 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.vue @@ -18,7 +18,7 @@ const error = ref | null>(null) const page = ref(1) const pageSize = ref(20) const total = ref(0) -const query = reactive({ keyword: '', status: '', pol: '', pod: '', soNo: '' }) +const query = reactive({ keyword: '', status: '' }) const statuses: Array<{ label: string; value: string }> = [ { label: '草稿', value: 'DRAFT' }, { label: '已提交', value: 'SUBMITTED' }, { label: '已确认', value: 'CONFIRMED' }, { label: '已放舱', value: 'RELEASED' }, { label: '失败', value: 'FAILED' }, { label: '变更中', value: 'CHANGE_PENDING' }, { label: '已取消', value: 'CANCELLED' }, @@ -26,16 +26,16 @@ const statuses: Array<{ label: string; value: string }> = [ const fields: QueryField[] = [ { key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号/客户', width: 220 }, { key: 'status', type: 'select', label: '状态', options: statuses }, - { key: 'pol', type: 'input', label: '起运港', width: 150 }, { key: 'pod', type: 'input', label: '目的港', width: 150 }, { key: 'soNo', type: 'input', label: 'S/O号', width: 150 }, ] const columns = [ { key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false }, - { prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'orderNo', label: '关联订单', minWidth: 140 }, { prop: 'customerName', label: '委托单位', minWidth: 160 }, - { prop: 'mblShipper', label: 'MB/L货人', minWidth: 150 }, { prop: 'mblConsignee', label: 'MB/L收人', minWidth: 150 }, { prop: 'pol', label: '起运港(POL)', minWidth: 125 }, { prop: 'pod', label: '目的港(POD)', minWidth: 125 }, - { prop: 'containerSummary', label: '箱型/箱量', minWidth: 120 }, { prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'soNo', label: 'S/O号', minWidth: 130 }, - { prop: 'cutoffAt', label: '预计截关', minWidth: 150 }, { prop: 'etd', label: 'ETD', minWidth: 140 }, { prop: 'atd', label: 'ATD', minWidth: 140 }, { prop: 'eta', label: 'ETA', minWidth: 140 }, { prop: 'ata', label: 'ATA', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' }, + { prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'customerName', label: '客户', minWidth: 160 }, + { prop: 'billOfLadingNo', label: '提单号', minWidth: 140 }, { prop: 'pol', label: '起运港', minWidth: 125 }, { prop: 'pod', label: '目的港', minWidth: 125 }, + { prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'bookingAgent', label: '订舱代理', minWidth: 140 }, + { prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'etd', label: '预计开船', minWidth: 140 }, + { prop: 'eta', label: '预计到港', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' }, ] -async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined, pol: String(query.pol || '') || undefined, pod: String(query.pod || '') || undefined, soNo: String(query.soNo || '') || undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } } +async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } } function open(id?: string) { void router.push(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') } function search() { page.value = 1; void load() } watch([page, pageSize], () => void load()); onMounted(load) diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts index 512c206b..5c2e7415 100644 --- a/src/modules/ocean-bookings/types.ts +++ b/src/modules/ocean-bookings/types.ts @@ -1,26 +1,41 @@ export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED' -export interface OceanBookingParty { partyType: string; companyName: string; contactName?: string | null; phone?: string | null; email?: string | null; address?: string | null } +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 OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null } export interface OceanBookingDraftData { customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[] + 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 - estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null; soNo?: string | null; vesselName?: string | null; voyageNo?: string | null - cutoffAt?: string | null; goodsDescription?: string | null; packageCount?: number | null; grossWeight?: string | null; volume?: string | null - amount?: string | null; currency?: string | null; remark?: string | null; orderNo?: string | null + placeOfDelivery?: string | null; serviceTerm?: string | null; freightTerm?: string | null + estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null; vesselName?: string | null; voyageNo?: string | null; remark?: string | null } export interface OceanBookingResource { - id: string; bookingNo: string | null; orderNo: string | null; customerId: string | null; customerName: string | null - status: OceanBookingStatus; carrierName: string | null; bookingAgent: string | null; vesselName: string | null; voyageNo: string | null - pol: string | null; pod: string | null; mblShipper: string | null; mblConsignee: string | null; soNo: string | null - cutoffAt: string | null; etd: string | null; atd: string | null; eta: string | null; ata: string | null - containerSummary: string | null; containers: OceanBookingContainer[]; amount: string | null; currency: string | null - remark: string | null; createdAt: string | null; updatedAt: string | null + id: string; bookingNo: string | null; status: OceanBookingStatus; version: number + customerId: string | null; customerName: string | null; billOfLadingNo: 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 + serviceTerm: string | null; freightTerm: string | null; estimatedSailingAt: string | null; estimatedArrivalAt: string | null + pendingVesselName: string | null; pendingVoyageNo: string | null; pendingEstimatedSailingAt: string | null; pendingEstimatedArrivalAt: string | null + changeReason: string | null; failureReason: string | null; cancellationReason: string | null; remark: string | null + createdAt: string | null; updatedAt: string | null + orderNo: string | null; carrierName: string | null; bookingAgent: string | null; pol: string | null; pod: string | null + mblShipper: string | null; mblConsignee: string | null; soNo: string | null; cutoffAt: string | null + etd: string | null; atd: string | null; eta: string | null; ata: string | null; containerSummary: string | null + containers: OceanBookingContainer[]; amount: string | null; currency: string | null } -export interface OceanBookingDetail extends OceanBookingResource { oceanBooking?: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; histories: OceanBookingStatusEvent[]; goodsDescription: string | null; packageCount: number | null; grossWeight: string | null; volume: string | null; statusHistory: OceanBookingStatusEvent[] } -export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; operatorName: string | null; occurredAt: string } +export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] } +export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string } export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number } -export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; pol?: string; pod?: string; soNo?: string; customerId?: string } -export interface OceanBookingSaveRequest { expectedVersion: number | null; data: OceanBookingDraftData } +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 OceanBookingChangeRequest extends OceanBookingActionRequest { + vesselName: string + voyageNo: string + estimatedSailingAt?: string | null + estimatedArrivalAt?: string | null + reason: string +} diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 92868a99..a4e55d36 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -145,10 +145,12 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('暂无费用数据') }) - it('does not expose the unconfirmed downstream workflow', () => { - expect(source).not.toContain('后半程作业') - expect(source).not.toContain('openWorkflow') - expect(source).not.toContain('workflowEligible') + it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => { + expect(source).toContain('UnifiedOrderWorkflowPanel') + expect(source).toContain("{ key: 'workflow', label: '后半程作业'") + expect(source).toContain(':read-only="true"') + expect(source).toContain(':show-delivery="false"') + expect(source).toContain("detail.value?.summary.inboundId") expect(routeSource).not.toContain('unified-order-workflow') }) }) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index ae73b297..20dbac01 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -26,6 +26,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LineClampText from '@/shared/components/LineClampText.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' +import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows' import { downloadWeightImage, getInboundMeasurement, @@ -105,6 +106,10 @@ const detailTabs = [ { key: 'other', label: '其他', icon: StickyNote }, ] +const detailTabOptions = computed(() => detail.value?.summary.inboundId + ? [...detailTabs, { key: 'workflow', label: '后半程作业', icon: ListChecks }] + : detailTabs) + const customerMeasurementColumns = [ { key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' }, { key: 'heightCm', label: '箱规高(cm)' }, { key: 'packageCount', label: '箱数' }, { key: 'unitWeightKg', label: '每箱毛重' }, @@ -789,7 +794,7 @@ onMounted(() => {
- +
@@ -922,7 +927,20 @@ onMounted(() => {
-