fix(appointments): normalize inbound plan counts before arrival date projection
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { http } from '@/shared/api/http'
|
||||||
|
import { projectContainerSchedule } from './schedule'
|
||||||
|
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||||
|
|
||||||
|
vi.mock('@/shared/api/http', async original => ({
|
||||||
|
...await original<typeof import('@/shared/api/http')>(),
|
||||||
|
http: { get: vi.fn(), post: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const range = { from: '2026-09-28', to: '2026-11-02' }
|
||||||
|
const bookingId = '9007199254740993'
|
||||||
|
// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
|
||||||
|
const wire = {
|
||||||
|
sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
|
||||||
|
warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
|
||||||
|
containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
|
||||||
|
vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
|
||||||
|
arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
|
||||||
|
emptyReturns: [], emptyReturnsReadable: true,
|
||||||
|
} })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function readSchedule(overrides: Record<string, unknown> = {}) {
|
||||||
|
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
|
||||||
|
const sources = await getCalendarSources(range)
|
||||||
|
const warehouses = await getWarehouseArrivals(range, [bookingId])
|
||||||
|
return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('warehouse calendar response to schedule', () => {
|
||||||
|
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
|
||||||
|
const { warehouses, event } = await readSchedule({ planCount })
|
||||||
|
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
|
||||||
|
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
|
||||||
|
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
|
||||||
|
expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
|
||||||
|
const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
|
||||||
|
expect(warehouses[0]?.planCount).toBe(2)
|
||||||
|
expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a manually maintained time when there is no inbound plan', async () => {
|
||||||
|
const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
|
||||||
|
expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not calculate a default when existing plans have no unified time', async () => {
|
||||||
|
const { event } = await readSchedule({ planCount: '2', canEdit: false })
|
||||||
|
expect(event?.date).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
|
||||||
|
const { event } = await readSchedule({ version: -1, canEdit: false })
|
||||||
|
expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
|
||||||
|
'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
|
||||||
|
await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -31,15 +31,35 @@ export interface WarehouseArrival {
|
|||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
}
|
}
|
||||||
export interface ScheduleRange { from: string; to: string }
|
export interface ScheduleRange { from: string; to: string }
|
||||||
|
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
||||||
|
planCount: string | number
|
||||||
|
plannedAt?: string | null
|
||||||
|
actualAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
|
||||||
|
function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
|
||||||
|
const count = row.planCount
|
||||||
|
if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
|
||||||
|
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||||
|
}
|
||||||
|
const planCount = Number(count)
|
||||||
|
if (!Number.isSafeInteger(planCount) || planCount < 0) {
|
||||||
|
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||||
|
}
|
||||||
|
return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
|
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
|
||||||
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
|
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
|
||||||
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
|
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
|
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
|
||||||
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
|
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
|
||||||
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
||||||
))
|
))
|
||||||
|
return rows.map(normalizeWarehouseArrival)
|
||||||
}
|
}
|
||||||
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
||||||
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user