diff --git a/AGENTS.md b/AGENTS.md index 8cf47f16..de7adabb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,8 @@ pnpm typecheck pnpm build ``` -- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。 +- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。 +- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。 - 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。 - 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。 - `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。 diff --git a/src/modules/appointment-calendar/schedule.ts b/src/modules/appointment-calendar/schedule.ts index 0f02b5bb..b3e2bccb 100644 --- a/src/modules/appointment-calendar/schedule.ts +++ b/src/modules/appointment-calendar/schedule.ts @@ -1,4 +1,4 @@ -import { addDays } from './calendar' +import { projectWarehouseArrival } from '@/modules/container-loading' import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi' export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const @@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W if (warehouses === null) continue const warehouse = warehouseMap.get(row.containerBookingId) warehouseMap.delete(row.containerBookingId) - const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0 - const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null) + const projected = projectWarehouseArrival(row.arrivalAt, warehouse) + const planned = projected.date if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned, - time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse, + time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse, canEdit: warehouse?.canEdit === true, - subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实', + subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实', fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode }, { label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned }, { label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts index e379e18e..4983df80 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.test.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts @@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => { beforeEach(() => vi.clearAllMocks()) it('queries visible containers and updates the whole-container expected arrival', async () => { - vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) - vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never) await listContainerArrivalTimes(['9001']) await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }) @@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) }) + + it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => { + const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 } + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] }) + expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }]) + for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] }) + await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效') + } + }) }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts index aedc9f82..c458b5bc 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.ts @@ -6,6 +6,14 @@ export interface ContainerArrivalTime { estimatedWarehouseArrivalAt: string | null actualWarehouseArrivalAt: string | null version: number + planCount: number +} + +function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime { + if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试') + const count = Number(item.planCount) + if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试') + return { ...item, version: Number(item.version), planCount: count } } const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query' @@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin queryPath, { sourceContainerBookingIds }, ) - return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) })) + return (unwrapApiResponse(response) || []).map(normalizeArrival) } export async function updateContainerExpectedArrival( @@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival( { headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } }, ) const result = unwrapApiResponse(response) - return { ...result, version: Number(result.version || 0) } + return normalizeArrival(result) } diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts index b37f139a..aaf8634f 100644 --- a/src/modules/container-loading/index.ts +++ b/src/modules/container-loading/index.ts @@ -1,5 +1,6 @@ export * from './api/platformContainerLoading' export * from './types' +export { projectWarehouseArrival } from './warehouseArrival' export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups' export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue' export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue' diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 7562bf70..6970882d 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) { } describe('ContainerDetailView empty return', () => { + it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => { + vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }]) + const wrapper = await mountDetail() + const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent + expect(value).toBe('2026-10-23(默认推算)') + expect(value).not.toContain('00:00') + }) it('passes the original container to schedule maintenance and reloads its projection after save', async () => { const wrapper = await mountDetail('DOMESTIC') const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }) @@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => { vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) - vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }]) }) it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => { diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 8d20e7b6..3c8b792f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -46,6 +46,7 @@ import { } from '../api/platformContainerLoading' import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages' import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes' +import { projectWarehouseArrival } from '../warehouseArrival' import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' }) @@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) { } const workbenchDetail = ref(null) const arrivalTime = ref(null) +const expectedArrivalText = computed(() => { + const row = workbenchDetail.value + const arrival = arrivalTime.value + const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt, + arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) + return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt) + : projected.date ? `${projected.date}(默认推算)` : '—' +}) const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action)) const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS')) @@ -726,7 +735,7 @@ onMounted(async () => {

提柜、卸柜与还空

提柜安排与执行直送卸货清单确认还柜
-
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
+
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ expectedArrivalText }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 950f0632..db7f05df 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -74,8 +74,8 @@ describe('ContainersView', () => { vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) - vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) - vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 }) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }]) + vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) @@ -584,6 +584,30 @@ describe('ContainersView', () => { }) }) + it('shows the calendar default from actual port arrival without submitting an invented time', async () => { + const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, + estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' }, + })) }) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }]) + const wrapper = await mountOverseasRoute() + const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')! + expect(picker).toBeDefined() + expect(picker.props('modelValue')).toBe('') + expect(updateContainerExpectedArrival).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('keeps a frozen missing arrival undated and prevents editing it', async () => { + const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) }) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }]) + const wrapper = await mountOverseasRoute() + const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')! + expect(picker.props('disabled')).toBe(true) + wrapper.unmount() + }) + it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => { const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC') vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({ diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 22098c00..6315ddda 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -40,6 +40,7 @@ import { type TerminalDrayageView, } from '../api/terminalDrayages' import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes' +import { projectWarehouseArrival } from '../warehouseArrival' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -285,6 +286,12 @@ function optional(value: string | null) { return value?.trim() || null } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } +function defaultArrivalDate(row: ContainerWorkbenchRow) { + const arrival = arrivalTimes.value[row.id] + const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt, + arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) + return projected.isDefault ? projected.date : null +} function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] } function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } @@ -577,7 +584,7 @@ async function saveDrayageAction() { } async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) { const current = arrivalTimes.value[row.id] - if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return + if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return arrivalSavingIds.value.push(row.id) try { const updated = await updateContainerExpectedArrival(row.id, { @@ -760,7 +767,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + @@ -772,7 +779,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - +
diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index 1fb043ea..c40aac10 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary { } export interface ContainerWorkbenchActualFacts { + arrivedDestinationPortAt?: string | null actualSailingAt?: string | null actualPortArrivalAt?: string | null actualWarehouseArrivalAt?: string | null diff --git a/src/modules/container-loading/warehouseArrival.test.ts b/src/modules/container-loading/warehouseArrival.test.ts new file mode 100644 index 00000000..5a9200e6 --- /dev/null +++ b/src/modules/container-loading/warehouseArrival.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { projectWarehouseArrival } from './warehouseArrival' + +describe('shared warehouse arrival projection', () => { + it.each([ + ['2026-10-21T15:49:00', '2026-10-23'], + ['2026-10-30T23:30:00-07:00', '2026-11-01'], + ['2026-12-31T23:30:00', '2027-01-02'], + ['2026-03-07T23:30:00-08:00', '2026-03-09'], + ])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => { + expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true }) + }) + it('keeps manually maintained and frozen dates instead of following port arrival', () => { + for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) { + expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false }) + } + }) + it('does not replace inconsistent or frozen missing dates with defaults', () => { + for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) { + expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull() + } + for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull() + }) +}) diff --git a/src/modules/container-loading/warehouseArrival.ts b/src/modules/container-loading/warehouseArrival.ts new file mode 100644 index 00000000..417f1b9c --- /dev/null +++ b/src/modules/container-loading/warehouseArrival.ts @@ -0,0 +1,20 @@ +export interface WarehouseArrivalPlan { + plannedAt: string | null + version: number + planCount: number +} + +/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */ +export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) { + const canDefault = !plan || plan.version === 0 && plan.planCount === 0 + let date = plan?.plannedAt?.slice(0, 10) || null + if (!date && canDefault && arrivalAt) { + const day = arrivalAt.slice(0, 10) + const calendarDate = new Date(`${day}T12:00:00Z`) + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) { + calendarDate.setUTCDate(calendarDate.getUTCDate() + 2) + date = calendarDate.toISOString().slice(0, 10) + } + } + return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault } +} diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 18cac6a3..9d310c78 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -63,6 +63,16 @@ 续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。 +## 自有业务列表字段补齐(2026-10-21) + +依据本轮用户字段截图 `codex-clipboard-9d532c45-a5f3-4f90-a8f2-f8a3cdfb8a3e.png`;用户确认派送仓库就是目的仓,最晚装车完成时间先空着。此前详情、运营登记、照片的承载与权限不变。 + +| 页面/操作 | 截图与当前确认 | 本次调整 | 验收 | +|---|---|---|---| +| 自有业务列表/读取字段 | 总价、单价、预约到达、实际到达各为独立列 | 拆开原合并列;一车一行,各到达时间保留目的仓标识,金额沿服务端原值 | 8项定向检查通过;待修改后人工截图 | +| 自有业务列表/仓库与时间 | 派送仓库等于目的仓;最晚装车完成时间暂无来源 | 派送仓库复用目的仓展示;最晚装车完成时间显示“—”,不拿计划发车替代;保留已有计划发车列 | 8项定向检查通过;待修改后人工截图 | +| 自有业务列表/字段名称 | 图中第一处“托数”装的是仓库名称 | 保留正确的“提货仓库”和唯一“实际托数”;“阶段”列改名“状态”,取值不变 | 8项定向检查通过;待修改后人工截图 | + ## 自有业务行操作样式核对(2026-10-21) 依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。 diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts index db3e4a3a..2c8e0f50 100644 --- a/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts @@ -10,7 +10,7 @@ export function outsourcedBusinessFixture(): OutsourcedBusinessDetail { actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8', photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [], }, - dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] }, + dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] }, cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [], } } diff --git a/src/modules/overseas-master-data/components/OwnedBusinessFixtures.ts b/src/modules/overseas-master-data/components/OwnedBusinessFixtures.ts index 157167df..247188c3 100644 --- a/src/modules/overseas-master-data/components/OwnedBusinessFixtures.ts +++ b/src/modules/overseas-master-data/components/OwnedBusinessFixtures.ts @@ -10,7 +10,7 @@ export function ownedBusinessFixture(): OwnedBusinessDetail { actualPalletCount: 1, operations: { money: null, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8', photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [], }, - dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] }, + dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] }, cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [], } } diff --git a/src/modules/overseas-master-data/pages/OwnedBusinessView.vue b/src/modules/overseas-master-data/pages/OwnedBusinessView.vue index 1fe40873..100e461f 100644 --- a/src/modules/overseas-master-data/pages/OwnedBusinessView.vue +++ b/src/modules/overseas-master-data/pages/OwnedBusinessView.vue @@ -37,11 +37,17 @@ const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' }, { key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 }, { prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 }, - { key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' }, + { key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' }, + { key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' }, { key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 }, - { key: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' }, - { key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' }, - { key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' }, + { key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' }, + { key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' }, + { key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, + { key: 'latestLoadingCompletedAt', label: '最晚装车完成时间', minWidth: 180, slotName: 'latestLoaded' }, + { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' }, + { key: 'scheduledArrival', label: '预约到达时间', minWidth: 190, slotName: 'scheduledArrival' }, + { key: 'actualArrival', label: '实际到达时间', minWidth: 190, slotName: 'actualArrival' }, + { key: 'stage', label: '状态', minWidth: 110, slotName: 'stage' }, ] function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' } async function load() { @@ -97,9 +103,13 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ }) - + + + - + + + diff --git a/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts b/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts index be651c4e..c55c656e 100644 --- a/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts +++ b/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts @@ -17,7 +17,7 @@ const Button = defineComponent({ props: ['disabled'], template: '