From 99cb25dac409c2bd8490b23b52910ced68b52dfa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 20:13:58 +0800 Subject: [PATCH] fix(appointments): normalize inbound plan counts before arrival date projection --- .../appointment-calendar/scheduleApi.test.ts | 71 +++++++++++++++++++ .../appointment-calendar/scheduleApi.ts | 22 +++++- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/modules/appointment-calendar/scheduleApi.test.ts diff --git a/src/modules/appointment-calendar/scheduleApi.test.ts b/src/modules/appointment-calendar/scheduleApi.test.ts new file mode 100644 index 00000000..80453af6 --- /dev/null +++ b/src/modules/appointment-calendar/scheduleApi.test.ts @@ -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(), + 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 = {}) { + 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('入库计划数量无效') + }, + ) +}) diff --git a/src/modules/appointment-calendar/scheduleApi.ts b/src/modules/appointment-calendar/scheduleApi.ts index edc3ad83..e8f19cf2 100644 --- a/src/modules/appointment-calendar/scheduleApi.ts +++ b/src/modules/appointment-calendar/scheduleApi.ts @@ -31,15 +31,35 @@ export interface WarehouseArrival { canEdit: boolean } export interface ScheduleRange { from: string; to: string } +type WarehouseArrivalWire = Omit & { + 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) { return unwrapApiResponse(await http.get, ApiEnvelope>( '/platform/v1/terminal-drayages/calendar-sources', { params: range, signal }, )) } export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) { - return unwrapApiResponse(await http.post, ApiEnvelope>( + const rows = unwrapApiResponse(await http.post, ApiEnvelope>( '/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal }, )) + return rows.map(normalizeWarehouseArrival) } export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number } export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {