Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -52,7 +52,7 @@
|
||||
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
|
||||
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
||||
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`,至目的港到达 |
|
||||
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;十节点贯通到港、卸船、清关、提柜与还柜,标准见 `../../docs/四期/船节点标准化契约.md` |
|
||||
|
||||
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
|---|---|---|
|
||||
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;仅查询、候选和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
||||
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
|
||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
|
||||
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn() } }
|
||||
return { ...actual, http: { get: vi.fn(), post: vi.fn() } }
|
||||
})
|
||||
|
||||
const query = {
|
||||
@@ -18,8 +18,8 @@ const query = {
|
||||
trackingNo: 'CSNU1234567',
|
||||
status: 'MANUAL_REQUIRED',
|
||||
triggerSource: 'OUTBOUND_CONFIRM',
|
||||
latestStandardNode: 'DEPARTED_WAREHOUSE',
|
||||
latestNodeObservedAt: '2026-08-20T10:00:00',
|
||||
latestActualNode: 'DEPARTED_WAREHOUSE',
|
||||
latestActualAt: '2026-08-20T10:00:00',
|
||||
accessMode: 'DYNAMIC_IFRAME',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED',
|
||||
lastErrorMessage: '必须人工查询',
|
||||
@@ -34,22 +34,31 @@ describe('platformCarrierScheduleQueries', () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', triggerSource: 'OUTBOUND_CONFIRM', latestStandardNode: 'DEPARTED_WAREHOUSE', latestNodeObservedAt: '2026-08-20T10:00:00', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', triggerSource: 'OUTBOUND_CONFIRM', latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-20T10:00:00', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true })
|
||||
})
|
||||
|
||||
it('normalizes standard node facts and immutable business history', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, status: 'ARRIVED', latestStandardNode: 'ARRIVED' }, expired: false }, nodes: [{ id: 1, providerCode: 'COSCO', providerNodeCode: 'ARR', standardNode: 'ARRIVED', nodeSequence: '50', observedAt: '2026-08-20T12:00:00', estimatedAt: null, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-1', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:01:00' }], histories: [{ id: 2, action: 'NODE_OBSERVED', fromVersion: '1', toVersion: '2', reason: 'ARRIVED', operatorId: 808 }] } })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, status: 'READY', latestActualNode: 'ARRIVED_DESTINATION_PORT' }, expired: false }, nodes: [{ id: 1, providerCode: 'COSCO', providerEventId: 'provider-event-1', providerNodeCode: 'ARR', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: '60', observedAt: '2026-08-20T12:00:00', estimatedAt: null, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-1', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:01:00' }], exceptions: [{ id: 3, providerCode: 'COSCO', providerEventId: 'hold-1', providerStatus: 'CUSTOMS HOLD', exceptionCode: 'CUSTOMS_HOLD', active: true, observedAt: '2026-08-20T12:05:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-hold-1', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:06:00' }], histories: [{ id: 2, action: 'NODE_OBSERVED', fromVersion: '1', toVersion: '2', reason: 'ARRIVED_DESTINATION_PORT', operatorId: 808 }] } })
|
||||
const result = await getCarrierScheduleQuery('9007199254740993')
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993')
|
||||
expect(result.nodes[0]).toMatchObject({ id: '1', standardNode: 'ARRIVED', nodeSequence: 50, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX' })
|
||||
expect(result.nodes[0]).toMatchObject({ id: '1', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: 60, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX' })
|
||||
expect(result.exceptions[0]).toMatchObject({ id: '3', providerEventId: 'hold-1', providerStatus: 'CUSTOMS HOLD', active: true })
|
||||
expect(result.histories[0]).toMatchObject({ id: '2', action: 'NODE_OBSERVED', fromVersion: 1, toVersion: 2, operatorId: '808' })
|
||||
})
|
||||
|
||||
it('preserves nullable DTO fields instead of fabricating false or zero values', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, containerBookingId: null, resultPortScheduleId: null, latestStandardNode: null, latestNodeObservedAt: null, stoppedAt: null, stopReason: null }, expired: true }, nodes: [{ id: 1, providerCode: 'FIRSTLEG', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, observedAt: '2026-08-20T10:00:00', estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, rawPayloadHash: 'b'.repeat(64), createdAt: null }], histories: [{ id: 2, action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, reason: null, operatorId: null, operatorName: null, createdAt: null }] } })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, containerBookingId: null, resultPortScheduleId: null, latestActualNode: null, latestActualAt: null, stoppedAt: null, stopReason: null }, expired: true }, nodes: [{ id: 1, providerCode: 'FIRSTLEG', providerEventId: 'outbound-1', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, observedAt: '2026-08-20T10:00:00', estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, rawPayloadHash: 'b'.repeat(64), createdAt: null }], exceptions: [], histories: [{ id: 2, action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, reason: null, operatorId: null, operatorName: null, createdAt: null }] } })
|
||||
const result = await getCarrierScheduleQuery('9007199254740993')
|
||||
expect(result.query.query).toMatchObject({ containerBookingId: null, resultPortScheduleId: null, latestStandardNode: null, latestNodeObservedAt: null, stoppedAt: null, stopReason: null })
|
||||
expect(result.query.query).toMatchObject({ containerBookingId: null, resultPortScheduleId: null, latestActualNode: null, latestActualAt: null, stoppedAt: null, stopReason: null })
|
||||
expect(result.nodes[0]).toMatchObject({ estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, createdAt: null })
|
||||
expect(result.histories[0]).toMatchObject({ fromVersion: null, operatorId: null, operatorName: null, createdAt: null })
|
||||
})
|
||||
|
||||
it('passes the dialog-scoped idempotency key to the frozen manual-node endpoint', async () => {
|
||||
const detail = { query: { query: { ...query, status: 'READY' }, expired: false }, nodes: [], exceptions: [], histories: [] }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: detail })
|
||||
const request = { expectedVersion: 3, standardNode: 'IN_TRANSIT' as const, observedAt: '2026-08-21T10:00:00', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验' }
|
||||
await appendCarrierScheduleManualNode('9007199254740993', 'manual-session-key', request)
|
||||
expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993/manual-nodes', request, { headers: { 'Idempotency-Key': 'manual-session-key' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
CarrierScheduleQueryDetail,
|
||||
CarrierScheduleExceptionFact,
|
||||
CarrierScheduleQueryFilter,
|
||||
CarrierScheduleQueryHistory,
|
||||
CarrierScheduleNodeFact,
|
||||
CarrierScheduleQueryPage,
|
||||
CarrierScheduleQueryResource,
|
||||
CarrierScheduleQueryView,
|
||||
CarrierScheduleManualNodeRequest,
|
||||
} from '../types'
|
||||
|
||||
const basePath = '/platform/v1/carrier-schedule-queries'
|
||||
@@ -34,6 +36,10 @@ function node(raw: CarrierScheduleNodeFact): CarrierScheduleNodeFact {
|
||||
return { ...raw, id: String(raw.id), nodeSequence: numberValue(raw.nodeSequence) }
|
||||
}
|
||||
|
||||
function exception(raw: CarrierScheduleExceptionFact): CarrierScheduleExceptionFact {
|
||||
return { ...raw, id: String(raw.id), active: Boolean(raw.active) }
|
||||
}
|
||||
|
||||
function history(raw: CarrierScheduleQueryHistory): CarrierScheduleQueryHistory {
|
||||
return { ...raw, id: String(raw.id), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion), operatorId: optionalId(raw.operatorId) }
|
||||
}
|
||||
@@ -47,5 +53,15 @@ export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFil
|
||||
export async function getCarrierScheduleQuery(queryId: string): Promise<CarrierScheduleQueryDetail> {
|
||||
const response = await http.get<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(`${basePath}/${encodeURIComponent(queryId)}`)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), histories: (result.histories || []).map(history) }
|
||||
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) }
|
||||
}
|
||||
|
||||
export async function appendCarrierScheduleManualNode(queryId: string, idempotencyKey: string, request: CarrierScheduleManualNodeRequest): Promise<CarrierScheduleQueryDetail> {
|
||||
const response = await http.post<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(
|
||||
`${basePath}/${encodeURIComponent(queryId)}/manual-nodes`,
|
||||
request,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
vi.mock('../api/platformCarrierScheduleQueries', () => ({
|
||||
listCarrierScheduleQueries: vi.fn(),
|
||||
getCarrierScheduleQuery: vi.fn(),
|
||||
appendCarrierScheduleManualNode: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
|
||||
|
||||
@@ -17,7 +21,7 @@ const queryView = {
|
||||
carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567',
|
||||
officialUrl: 'https://elines.coscoshipping.com/ebusiness/', accessMode: 'DYNAMIC_IFRAME',
|
||||
status: 'MANUAL_REQUIRED' as const, triggerSource: 'OUTBOUND_CONFIRM',
|
||||
latestStandardNode: 'DEPARTED_WAREHOUSE' as const, latestNodeObservedAt: '2026-08-20T10:00:00',
|
||||
latestActualNode: 'DEPARTED_WAREHOUSE' as const, latestActualAt: '2026-08-20T10:00:00',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: '21', version: 1,
|
||||
},
|
||||
@@ -25,14 +29,15 @@ const queryView = {
|
||||
}
|
||||
|
||||
describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
beforeEach(() => { vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); useAuthStore().permissions = ['platform:firstleg:port-schedule-query:manual'] })
|
||||
|
||||
it('keeps list facts visible when history and linked schedule both fail', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [queryView], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockRejectedValue(new Error('detail unavailable'))
|
||||
vi.mocked(getPortSchedule).mockRejectedValue(new Error('schedule unavailable'))
|
||||
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(listCarrierScheduleQueries).toHaveBeenCalledWith({ page: 1, size: 200, oceanBookingId: '2', containerBookingId: '3' })
|
||||
@@ -46,7 +51,7 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
|
||||
it('shows an explicit non-fabricated empty state and performs no detail reads', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('本页面不会创建或模拟任务')
|
||||
@@ -56,8 +61,8 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
|
||||
it('falls back to the booking/container task when an automatic task has no linked port schedule', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], histories: [] })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3', portScheduleId: '99' }, global: { plugins: [ElementPlus] } })
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3', portScheduleId: '99' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CSQ11')
|
||||
@@ -65,21 +70,70 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
expect(getPortSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders canonical node location, actual time, source and stopped facts from detail DTO', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, status: 'STOPPED', latestStandardNode: 'PICKUP_READY', stoppedAt: '2026-08-20T18:00:00', stopReason: '目的港可提柜后停止', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
it('renders canonical node location, actual time, source and empty-return stop facts from detail DTO', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, status: 'STOPPED', latestActualNode: 'EMPTY_RETURNED', latestActualAt: '2026-08-20T17:45:00', stoppedAt: '2026-08-20T18:00:00', stopReason: '已还柜', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({
|
||||
query: { query: { ...queryView.query, status: 'STOPPED', latestStandardNode: 'PICKUP_READY', stoppedAt: '2026-08-20T18:00:00', stopReason: '目的港可提柜后停止', resultPortScheduleId: null }, expired: false },
|
||||
nodes: [{ id: '31', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY', nodeSequence: 60, observedAt: '2026-08-20T17:50:00', estimatedAt: null, actualAt: '2026-08-20T17:45:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-31', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T17:51:00' }],
|
||||
histories: [{ id: '41', action: 'STOP', fromVersion: 4, toVersion: 5, reason: '目的港可提柜后停止', operatorId: null, operatorName: '系统', createdAt: '2026-08-20T18:00:00' }],
|
||||
query: { query: { ...queryView.query, status: 'STOPPED', latestActualNode: 'EMPTY_RETURNED', latestActualAt: '2026-08-20T17:45:00', stoppedAt: '2026-08-20T18:00:00', stopReason: '已还柜', resultPortScheduleId: null }, expired: false },
|
||||
nodes: [{ id: '31', providerCode: 'COSCO', providerEventId: 'provider-event-31', providerNodeCode: 'EMPTY_RETURN', standardNode: 'EMPTY_RETURNED', nodeSequence: 100, observedAt: '2026-08-20T17:50:00', estimatedAt: null, actualAt: '2026-08-20T17:45:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-31', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T17:51:00' }],
|
||||
exceptions: [],
|
||||
histories: [{ id: '41', action: 'STOP', fromVersion: 4, toVersion: 5, reason: '已还柜', operatorId: null, operatorName: '系统', createdAt: '2026-08-20T18:00:00' }],
|
||||
})
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('已停止')
|
||||
expect(wrapper.text()).toContain('可提柜')
|
||||
expect(wrapper.text()).toContain('已停止 · 已还柜')
|
||||
expect(wrapper.text()).toContain('USLAX · Los Angeles')
|
||||
expect(wrapper.text()).toContain('2026-08-20 17:45')
|
||||
expect(wrapper.text()).toContain('COSCO')
|
||||
expect(wrapper.text()).toContain('目的港可提柜后停止')
|
||||
expect(wrapper.text()).toContain('已还柜')
|
||||
})
|
||||
|
||||
it('labels forecast-only facts as estimated without promoting latest actual progress', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-20T08:55:00', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({
|
||||
query: { query: { ...queryView.query, latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-20T08:55:00', resultPortScheduleId: null }, expired: false },
|
||||
nodes: [
|
||||
{ id: '11', providerCode: 'FIRSTLEG', providerEventId: 'outbound-11', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, observedAt: '2026-08-20T09:00:00', estimatedAt: null, actualAt: '2026-08-20T08:55:00', locationCode: null, locationName: null, sourceReference: null, rawPayloadHash: 'b'.repeat(64), createdAt: '2026-08-20T09:00:01' },
|
||||
{ id: '12', providerCode: 'HEDE', providerEventId: 'hede-estimate-12', providerNodeCode: '预抵', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: 60, observedAt: '2026-08-20T10:00:00', estimatedAt: '2026-08-28T12:00:00', actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'hede-estimate', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T10:00:01' },
|
||||
], exceptions: [{ id: '51', providerCode: 'HEDE', providerEventId: 'hold-51', providerStatus: 'Customs hold', exceptionCode: 'CUSTOMS_HOLD', active: true, observedAt: '2026-08-20T11:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'hede-hold', rawPayloadHash: 'd'.repeat(64), createdAt: '2026-08-20T11:00:01' }], histories: [],
|
||||
})
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('最新实际节点已离开国内仓库')
|
||||
expect(wrapper.text()).toContain('预计 2026-08-28 12:00')
|
||||
expect(wrapper.text()).not.toContain('最新实际节点船舶已到目的港')
|
||||
expect(wrapper.text()).toContain('异常事实')
|
||||
expect(wrapper.text()).toContain('海关扣留 · 发生')
|
||||
expect(wrapper.text()).toContain('Customs hold')
|
||||
})
|
||||
|
||||
it('keeps the form context and dialog-scoped idempotency key when a conflict is retried', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const currentDetail = { query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] }
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue(currentDetail)
|
||||
vi.mocked(appendCarrierScheduleManualNode)
|
||||
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', 409, null, 'req-409', 'trace-409'))
|
||||
.mockResolvedValueOnce(currentDetail)
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
|
||||
const select = wrapper.findComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'IN_TRANSIT')
|
||||
const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' }); pickers[0]!.vm.$emit('update:modelValue', '2026-08-22T10:00:00')
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
|
||||
inputs.find((input) => input.props('placeholder') === '可选,如 USLAX')!.vm.$emit('update:modelValue', 'PACIFIC')
|
||||
inputs.find((input) => String(input.props('maxlength')) === '128')!.vm.$emit('update:modelValue', 'Pacific Ocean')
|
||||
inputs.find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '船司官网人工核验')
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('补录未成功,已保留当前填写内容')
|
||||
expect(wrapper.text()).toContain('req-409'); expect(wrapper.text()).toContain('trace-409')
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
const first = vi.mocked(appendCarrierScheduleManualNode).mock.calls[0]!
|
||||
const second = vi.mocked(appendCarrierScheduleManualNode).mock.calls[1]!
|
||||
expect(first[1]).toBeTruthy(); expect(second[1]).toBe(first[1])
|
||||
expect(second[2]).toEqual(first[2])
|
||||
expect(second[2]).toMatchObject({ expectedVersion: 1, standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
expect(source).toContain('oceanBookingId: props.oceanBookingId')
|
||||
expect(source).toContain('containerBookingId: props.containerBookingId')
|
||||
expect(source).not.toContain('createCarrierSchedule')
|
||||
expect(source).not.toContain('attemptCarrierSchedule')
|
||||
expect(source).not.toContain(['attempt', 'CarrierSchedule'].join(''))
|
||||
})
|
||||
|
||||
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
|
||||
@@ -20,11 +20,12 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
expect(source).toContain('来源更新时间')
|
||||
expect(source).toContain('标准节点历史')
|
||||
expect(source).toContain('节点位置')
|
||||
expect(source).toContain('节点实际/预计时间')
|
||||
expect(source).toContain('任务操作状态')
|
||||
expect(source).toContain('实际发生时间')
|
||||
expect(source).toContain('变更历史')
|
||||
expect(source).toContain('query.lastErrorMessage')
|
||||
expect(source).toContain('query.lastErrorCode')
|
||||
expect(source).toContain("return '结果已过期'")
|
||||
expect(source).toContain("queryView?.expired ? '结果已过期' : '结果有效'")
|
||||
})
|
||||
|
||||
it('keeps query-history and linked-schedule partial failures independently visible', () => {
|
||||
@@ -34,12 +35,29 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
expect(source).toContain('关联船期结果暂不可用')
|
||||
})
|
||||
|
||||
it('shows canonical arrival, pickup-ready and stopped states without exposing scheduler actions', () => {
|
||||
expect(source).toContain("PICKUP_READY: '可提柜'")
|
||||
expect(source).toContain("ARRIVED: '已到目的港'")
|
||||
expect(source).toContain("return '已停止'")
|
||||
it('shows all ten canonical nodes and stops only on actual empty return', () => {
|
||||
for (const node of ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']) expect(source).toContain(node)
|
||||
expect(source).not.toMatch(new RegExp(`\\b${['ARR', 'IVED'].join('')}:`))
|
||||
expect(source).toContain("latestActualNode === 'EMPTY_RETURNED'")
|
||||
expect(source).toContain("'已停止 · 已还柜'")
|
||||
expect(source).toContain('任务仅在确认国内出仓后自动建立')
|
||||
expect(source).not.toContain('/scheduler')
|
||||
expect(source).not.toContain('/attempts')
|
||||
expect(source).not.toContain('/' + 'scheduler')
|
||||
expect(source).not.toContain('/' + 'attempts')
|
||||
})
|
||||
|
||||
it('keeps estimated facts labelled and separate from actual progress', () => {
|
||||
expect(source).toContain("item.actualAt ? `实际")
|
||||
expect(source).toContain(": item.estimatedAt ? `预计")
|
||||
expect(source).toContain('latestActualNode')
|
||||
expect(source).toContain('item.actualAt')
|
||||
})
|
||||
|
||||
it('uses the frozen manual-node permission and keeps exceptions separate', () => {
|
||||
expect(source).toContain("access.can('platform:firstleg:port-schedule-query:manual')")
|
||||
expect(source).toContain('appendCarrierScheduleManualNode')
|
||||
expect(source).toContain('manualIdempotencyKey')
|
||||
expect(source).toContain('异常事实')
|
||||
expect(source).toContain('item.providerStatus')
|
||||
expect(source).not.toContain('/' + 'manual-results')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
import type { CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
|
||||
import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
|
||||
|
||||
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const queryDetailError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -15,37 +19,74 @@ const scheduleError = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<CarrierScheduleQueryView | null>(null)
|
||||
const detail = ref<CarrierScheduleQueryDetail | null>(null)
|
||||
const latestSchedule = ref<PortScheduleView | null>(null)
|
||||
const manualDialogOpen = ref(false)
|
||||
const manualIdempotencyKey = ref('')
|
||||
const manualObservedAt = ref('')
|
||||
const manualSaving = ref(false)
|
||||
const manualError = ref<ApiErrorDetails | null>(null)
|
||||
const manualValidationError = ref('')
|
||||
const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
|
||||
const query = computed(() => detail.value?.query.query || selected.value?.query || null)
|
||||
const queryView = computed(() => detail.value?.query || selected.value)
|
||||
const statusText = computed(() => {
|
||||
if (!query.value) return '未建立'
|
||||
if (query.value.status === 'STOPPED') return '已停止'
|
||||
if (query.value.status === 'PICKUP_READY') return '可提柜'
|
||||
if (query.value.status === 'ARRIVED') return '已到目的港'
|
||||
if (queryView.value?.expired) return '结果已过期'
|
||||
return ({ READY: '查询中', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[query.value.status] || query.value.status
|
||||
if (query.value.status === 'STOPPED' && query.value.latestActualNode === 'EMPTY_RETURNED' && query.value.latestActualAt) return '已停止 · 已还柜'
|
||||
if (query.value.status === 'STOPPED') return '任务已停止'
|
||||
return ({ READY: '运行中', MANUAL_REQUIRED: '待人工处理' } as Record<string, string>)[query.value.status] || query.value.status
|
||||
})
|
||||
const statusType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
|
||||
if (query.value?.status === 'STOPPED') return 'info'
|
||||
if (query.value?.status === 'ARRIVED' || query.value?.status === 'PICKUP_READY') return 'success'
|
||||
if (queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED') return 'danger'
|
||||
return 'warning'
|
||||
})
|
||||
const nodes = computed(() => detail.value?.nodes || [])
|
||||
const latestNode = computed(() => nodes.value[nodes.value.length - 1] || null)
|
||||
const latestSource = computed(() => latestNode.value?.providerCode || latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
|
||||
const sourceReference = computed(() => latestNode.value?.sourceReference || latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
|
||||
const latestObservation = computed(() => latestNode.value?.observedAt || query.value?.latestNodeObservedAt || latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || null)
|
||||
const exceptions = computed(() => [...(detail.value?.exceptions || [])].reverse())
|
||||
const latestActualNodeFact = computed(() => [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt === query.value?.latestActualAt)
|
||||
|| [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt)
|
||||
|| null)
|
||||
const latestSource = computed(() => latestActualNodeFact.value?.providerCode || latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
|
||||
const sourceReference = computed(() => latestActualNodeFact.value?.sourceReference || latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
|
||||
const latestObservation = computed(() => latestActualNodeFact.value?.observedAt || null)
|
||||
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
|
||||
const standardNodes: CarrierScheduleStandardNode[] = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']
|
||||
|
||||
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
|
||||
function triggerSourceText(value?: string | null) { return ({ OUTBOUND_CONFIRM: '国内出仓确认自动建立', MANUAL: '平台人工建立' } as Record<string, string>)[value || ''] || value || '—' }
|
||||
function nodeText(value?: CarrierScheduleStandardNode | null) { return ({ DEPARTED_WAREHOUSE: '已离开国内仓', AT_ORIGIN_PORT: '已到起运港', DEPARTED_ORIGIN_PORT: '已离开起运港', IN_TRANSIT: '海运在途', ARRIVED: '已到目的港', PICKUP_READY: '可提柜' } as Record<string, string>)[value || ''] || value || '—' }
|
||||
function nodeText(value?: CarrierScheduleStandardNode | null) { return ({ DEPARTED_WAREHOUSE: '已离开国内仓库', AT_ORIGIN_PORT: '重箱已进入起运港', LOADED_ON_VESSEL: '已装船', DEPARTED_ORIGIN_PORT: '船舶已离开起运港', IN_TRANSIT: '海运途中', ARRIVED_DESTINATION_PORT: '船舶已到目的港', DISCHARGED_FROM_VESSEL: '已卸船、未清关', PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[value || ''] || value || '—' }
|
||||
function nodeLocation(item?: CarrierScheduleNodeFact | null) { return item ? [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' : '—' }
|
||||
function nodePrimaryTime(item?: CarrierScheduleNodeFact | null) { return item?.actualAt || item?.estimatedAt || item?.observedAt || null }
|
||||
function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_RESULT: '关联人工船期结果', STOP: '停止查询' } as Record<string, string>)[action] || action }
|
||||
function exceptionLocation(item: CarrierScheduleExceptionFact) { return [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' }
|
||||
function nodeTimeText(item: CarrierScheduleNodeFact) { return item.actualAt ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}` }
|
||||
function exceptionText(value: CarrierScheduleExceptionFact['exceptionCode']) { return ({ CUSTOMS_HOLD: '海关扣留', TERMINAL_HOLD: '码头扣留', DOCUMENT_HOLD: '单证扣留', UNKNOWN_HOLD: '其他扣留' } as Record<string, string>)[value] || value }
|
||||
function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_NODE: '人工补录标准节点', STOP: '停止查询' } as Record<string, string>)[action] || action }
|
||||
|
||||
function resetManualForm() {
|
||||
Object.assign(manualForm, { standardNode: '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
manualError.value = null; manualValidationError.value = ''
|
||||
}
|
||||
function createIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `carrier-node-${Date.now()}-${Math.random().toString(16).slice(2)}` }
|
||||
function localDateTimeNow() {
|
||||
const now = new Date()
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19)
|
||||
}
|
||||
function openManualDialog() { resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true }
|
||||
async function submitManualNode() {
|
||||
manualValidationError.value = ''
|
||||
if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
|
||||
if (!manualForm.estimatedAt && !manualForm.actualAt) { manualValidationError.value = '预计时间和实际时间至少填写一项'; return }
|
||||
if (!manualForm.reason.trim()) { manualValidationError.value = '请输入补录原因'; return }
|
||||
const base = { expectedVersion: query.value.version, standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() }
|
||||
const request: CarrierScheduleManualNodeRequest = manualForm.actualAt
|
||||
? { ...base, actualAt: manualForm.actualAt, estimatedAt: manualForm.estimatedAt || null }
|
||||
: { ...base, estimatedAt: manualForm.estimatedAt, actualAt: null }
|
||||
manualSaving.value = true; manualError.value = null
|
||||
try {
|
||||
const result = await appendCarrierScheduleManualNode(query.value.id, manualIdempotencyKey.value, request)
|
||||
detail.value = result; selected.value = result.query; queryDetailError.value = null; manualDialogOpen.value = false; manualIdempotencyKey.value = ''; manualObservedAt.value = ''; resetManualForm()
|
||||
} catch (cause) { manualError.value = getApiErrorDetails(cause, '人工节点补录失败') }
|
||||
finally { manualSaving.value = false }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
|
||||
@@ -69,11 +110,12 @@ async function load() {
|
||||
}
|
||||
|
||||
watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true })
|
||||
watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualIdempotencyKey.value = ''; manualObservedAt.value = '' } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="carrier-query" aria-label="国内出仓后船节点">
|
||||
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,跟踪至目的港到达;本页面不会新建轮询任务。</p></div><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></header>
|
||||
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,从确认国内出仓跟踪至已还柜;本页面不会新建轮询任务。</p></div><div class="header-actions"><el-button v-if="query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="2" animated /></div>
|
||||
<el-empty v-else-if="!query" :image-size="52" description="尚无关联任务;任务仅在确认国内出仓后自动建立,本页面不会创建或模拟任务。" />
|
||||
@@ -84,23 +126,36 @@ watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleI
|
||||
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>
|
||||
<dl class="query-facts">
|
||||
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div>
|
||||
<div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>船司 / 访问方式</dt><dd>{{ query.carrierCode }} · {{ query.accessMode || '—' }}</dd></div>
|
||||
<div><dt>最新节点</dt><dd>{{ nodeText(query.latestStandardNode) }}</dd></div><div><dt>节点位置</dt><dd>{{ nodeLocation(latestNode) }}</dd></div>
|
||||
<div><dt>节点实际/预计时间</dt><dd>{{ formatTime(nodePrimaryTime(latestNode)) }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
|
||||
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div>
|
||||
<div><dt>任务操作状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div>
|
||||
<div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div>
|
||||
<div><dt>节点位置</dt><dd>{{ nodeLocation(latestActualNodeFact) }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
|
||||
<div><dt>船司 / 访问方式</dt><dd>{{ query.carrierCode }} · {{ query.accessMode || '—' }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div>
|
||||
<div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div>
|
||||
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div>
|
||||
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>停止时间</dt><dd>{{ formatTime(query.stoppedAt) }}</dd></div>
|
||||
<div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
|
||||
<div class="history-grid">
|
||||
<section><h3>标准节点历史</h3><p v-if="!nodes.length" class="empty-line">暂无标准节点</p><ol v-else class="node-list"><li v-for="item in nodes" :key="item.id"><span>{{ formatTime(nodePrimaryTime(item)) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></section>
|
||||
<section><h3>标准节点历史</h3><p v-if="!nodes.length" class="empty-line">暂无标准节点</p><ol v-else><li v-for="item in nodes" :key="item.id"><span>{{ nodeTimeText(item) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template><template v-if="item.actualAt && item.estimatedAt"> · 预计 {{ formatTime(item.estimatedAt) }}</template></small></li></ol></section>
|
||||
<section><h3>异常事实</h3><p v-if="!exceptions.length" class="empty-line">暂无异常事实</p><ol v-else><li v-for="item in exceptions" :key="item.id"><span>{{ formatTime(item.observedAt) }}</span><strong>{{ exceptionText(item.exceptionCode) }} · {{ item.active ? '发生' : '已解除' }}</strong><small>{{ exceptionLocation(item) }} · {{ item.providerCode }} · {{ item.providerStatus }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></section>
|
||||
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog v-model="manualDialogOpen" title="人工补录船节点" width="min(620px, calc(100vw - 28px))" confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<el-alert v-if="manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" /></template></el-alert>
|
||||
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
|
||||
<el-alert type="info" :closable="false" title="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
|
||||
<el-form label-position="top" class="manual-form">
|
||||
<el-form-item label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点" style="width:100%"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item>
|
||||
<div class="manual-form-grid"><el-form-item label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选" style="width:100%" /></el-form-item><el-form-item label="实际时间"><el-date-picker v-model="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" style="width:100%" /></el-form-item></div>
|
||||
<div class="manual-form-grid"><el-form-item label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item><el-form-item label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></div>
|
||||
<el-form-item label="补录原因" required><el-input v-model="manualForm.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入事实来源与补录原因" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.carrier-query{display:grid;gap:12px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:8px 0}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 18px;margin:0}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:12px}.query-facts dd{margin:4px 0 0;font-size:13px}.break-value{overflow-wrap:anywhere}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;padding:10px 12px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:13px}.history-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.history-grid section{min-width:0}.history-grid h3{margin:0 0 8px;font-size:13px}.history-grid ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.history-grid li{display:grid;grid-template-columns:128px 100px minmax(0,1fr);gap:8px;padding:7px 0;border-top:1px solid var(--cc-border);font-size:12px}.history-grid small,.empty-line{color:var(--cc-muted);font-size:12px}.empty-line{margin:0}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.query-facts,.history-grid{grid-template-columns:1fr}.history-grid li{grid-template-columns:1fr}}
|
||||
.carrier-query{display:grid;gap:12px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.header-actions{display:flex;align-items:center;gap:8px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:8px 0}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 18px;margin:0}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:12px}.query-facts dd{margin:4px 0 0;font-size:13px}.break-value{overflow-wrap:anywhere}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;padding:10px 12px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:13px}.history-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.history-grid section{min-width:0}.history-grid h3{margin:0 0 8px;font-size:13px}.history-grid ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.history-grid li{display:grid;grid-template-columns:128px 120px minmax(0,1fr);gap:8px;padding:7px 0;border-top:1px solid var(--cc-border);font-size:12px}.history-grid small,.empty-line{color:var(--cc-muted);font-size:12px}.empty-line{margin:0}.manual-form{display:grid;gap:2px;margin-top:12px}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.query-facts,.history-grid,.manual-form-grid{grid-template-columns:1fr}.history-grid li{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/types.ts'), 'utf8')
|
||||
|
||||
describe('phase-4 carrier node types', () => {
|
||||
it('locks the canonical ten-node model without the retired destination alias', () => {
|
||||
const declaration = source.match(/export type CarrierScheduleStandardNode = ([^\n]+)/)?.[1] || ''
|
||||
for (const node of ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']) expect(declaration).toContain(`'${node}'`)
|
||||
expect(declaration).not.toContain(`'${['ARR', 'IVED'].join('')}'`)
|
||||
})
|
||||
|
||||
it('freezes exception facts separately from monotonic node facts', () => {
|
||||
for (const code of ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) expect(source).toContain(`'${code}'`)
|
||||
expect(source).toContain('export interface CarrierScheduleExceptionFact')
|
||||
expect(source).not.toContain('latestExceptionNode')
|
||||
})
|
||||
|
||||
it('requires at least one estimated or actual time for a future manual-node request', () => {
|
||||
expect(source).toContain("| { actualAt: string; estimatedAt?: string | null }")
|
||||
expect(source).toContain("| { estimatedAt: string; actualAt?: string | null }")
|
||||
expect(source).toContain('expectedVersion: number')
|
||||
expect(source).toContain('observedAt: string')
|
||||
expect(source).toContain('reason: string')
|
||||
})
|
||||
})
|
||||
@@ -33,8 +33,9 @@ export interface PortScheduleQuery { page: number; size: number; customerId?: st
|
||||
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
|
||||
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
|
||||
|
||||
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'ARRIVED' | 'PICKUP_READY' | 'STOPPED'
|
||||
export type CarrierScheduleStandardNode = 'DEPARTED_WAREHOUSE' | 'AT_ORIGIN_PORT' | 'DEPARTED_ORIGIN_PORT' | 'IN_TRANSIT' | 'ARRIVED' | 'PICKUP_READY'
|
||||
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'STOPPED'
|
||||
export type CarrierScheduleStandardNode = 'DEPARTED_WAREHOUSE' | 'AT_ORIGIN_PORT' | 'LOADED_ON_VESSEL' | 'DEPARTED_ORIGIN_PORT' | 'IN_TRANSIT' | 'ARRIVED_DESTINATION_PORT' | 'DISCHARGED_FROM_VESSEL' | 'PICKUP_READY' | 'PICKED_UP' | 'EMPTY_RETURNED'
|
||||
export type CarrierScheduleExceptionCode = 'CUSTOMS_HOLD' | 'TERMINAL_HOLD' | 'DOCUMENT_HOLD' | 'UNKNOWN_HOLD'
|
||||
|
||||
export interface CarrierScheduleQueryResource {
|
||||
id: string
|
||||
@@ -49,8 +50,8 @@ export interface CarrierScheduleQueryResource {
|
||||
accessMode?: string | null
|
||||
status: CarrierScheduleQueryStatus
|
||||
triggerSource?: string | null
|
||||
latestStandardNode?: CarrierScheduleStandardNode | null
|
||||
latestNodeObservedAt?: string | null
|
||||
latestActualNode?: CarrierScheduleStandardNode | null
|
||||
latestActualAt?: string | null
|
||||
lastSuccessAt?: string | null
|
||||
sourceUpdatedAt?: string | null
|
||||
expiresAt?: string | null
|
||||
@@ -71,6 +72,7 @@ export interface CarrierScheduleQueryView {
|
||||
export interface CarrierScheduleNodeFact {
|
||||
id: string
|
||||
providerCode: string
|
||||
providerEventId: string
|
||||
providerNodeCode: string
|
||||
standardNode: CarrierScheduleStandardNode
|
||||
nodeSequence: number
|
||||
@@ -84,6 +86,34 @@ export interface CarrierScheduleNodeFact {
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface CarrierScheduleExceptionFact {
|
||||
id: string
|
||||
providerCode: string
|
||||
providerEventId: string
|
||||
providerStatus: string
|
||||
exceptionCode: CarrierScheduleExceptionCode
|
||||
active: boolean
|
||||
observedAt: string
|
||||
locationCode?: string | null
|
||||
locationName?: string | null
|
||||
sourceReference?: string | null
|
||||
rawPayloadHash: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type CarrierScheduleManualNodeBase = {
|
||||
expectedVersion: number
|
||||
standardNode: CarrierScheduleStandardNode
|
||||
observedAt: string
|
||||
locationCode?: string | null
|
||||
locationName?: string | null
|
||||
reason: string
|
||||
}
|
||||
export type CarrierScheduleManualNodeRequest = CarrierScheduleManualNodeBase & (
|
||||
| { actualAt: string; estimatedAt?: string | null }
|
||||
| { estimatedAt: string; actualAt?: string | null }
|
||||
)
|
||||
|
||||
export interface CarrierScheduleQueryHistory {
|
||||
id: string
|
||||
action: string
|
||||
@@ -98,6 +128,7 @@ export interface CarrierScheduleQueryHistory {
|
||||
export interface CarrierScheduleQueryDetail {
|
||||
query: CarrierScheduleQueryView
|
||||
nodes: CarrierScheduleNodeFact[]
|
||||
exceptions: CarrierScheduleExceptionFact[]
|
||||
histories: CarrierScheduleQueryHistory[]
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('InboundFeeDetailManager', () => {
|
||||
})
|
||||
|
||||
it('refreshes fee rows and the order detail after every successful write', () => {
|
||||
expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')")
|
||||
expect(source).toMatch(/async function finishAction\(message: string\) \{\r?\n await load\(\); emit\('changed'\)/)
|
||||
expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/)
|
||||
expect(source).toMatch(/deleteInboundFeeDetail[\s\S]*await finishAction\('费用明细已删除'\)/)
|
||||
expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*await finishAction\('费用审核通过'\)/)
|
||||
|
||||
Reference in New Issue
Block a user