From 5a46198bf0d8923e1cfad64ecc3844472287d35f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 17:02:27 +0800 Subject: [PATCH] feat(appointments): add FBA creation and destination filtering --- .../AppointmentCalendarPanel.test.ts | 13 +- .../AppointmentCalendarPanel.vue | 8 +- .../AppointmentCalendarView.test.ts | 29 +++- .../AppointmentCalendarView.vue | 10 +- .../AppointmentCreatePanel.test.ts | 131 ++++++++++++++++ .../AppointmentCreatePanel.vue | 145 ++++++++++++++++++ .../appointment-calendar/PROTOTYPE_MATRIX.md | 13 +- .../WorkCalendarPanel.test.ts | 2 +- .../WorkCreateAppointmentForm.vue | 6 +- .../appointment-calendar/calendar.test.ts | 17 +- src/modules/appointment-calendar/calendar.ts | 11 +- .../appointment-calendar/planningApi.test.ts | 11 +- .../appointment-calendar/planningApi.ts | 8 +- src/styles/adminDensity.test.ts | 3 +- 14 files changed, 382 insertions(+), 25 deletions(-) create mode 100644 src/modules/appointment-calendar/AppointmentCreatePanel.test.ts create mode 100644 src/modules/appointment-calendar/AppointmentCreatePanel.vue diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index 38fc7982..3ca8267c 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -13,7 +13,9 @@ describe('four-type appointment calendar', () => { expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓') expect(wrapper.findAll('.detail-event')).toHaveLength(4) expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排') - expect(wrapper.findAll('.calendar-day')).toHaveLength(42) + expect(wrapper.findAll('.calendar-day')).toHaveLength(35) + expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true) + expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false) expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/) }) it('filters locally without requesting another range', async () => { @@ -24,6 +26,15 @@ describe('four-type appointment calendar', () => { expect(wrapper.text()).toContain('当天暂无安排') expect(wrapper.emitted('range')).toBeUndefined() }) + it('offers creation in the header only when authorized', async () => { + const wrapper = render() + expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false) + await wrapper.setProps({ canCreate: true }) + await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click') + expect(wrapper.emitted('create')).toEqual([[]]) + expect(wrapper.emitted('range')).toBeUndefined() + wrapper.unmount() + }) it('changes the selected date locally and requests one range per mode or period change', async () => { const wrapper = render() await wrapper.get('[data-date="2026-10-20"]').trigger('click') diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index fd4bc039..cb9cc19d 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -1,12 +1,13 @@ + diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md index 43202134..27b6b138 100644 --- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -7,12 +7,23 @@ | 页面 | 操作 | 当前确认承载与布局 | 实现结论 | |---|---|---|---| | 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 | +| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 | | 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 | -| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 | +| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 | +| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 | +| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 | | 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 | | 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 | | 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 | +2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。 + +2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。 + +验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。 + +2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。 + 2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。 发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。 diff --git a/src/modules/appointment-calendar/WorkCalendarPanel.test.ts b/src/modules/appointment-calendar/WorkCalendarPanel.test.ts index 1ecf1057..1bdb83c2 100644 --- a/src/modules/appointment-calendar/WorkCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/WorkCalendarPanel.test.ts @@ -57,7 +57,7 @@ it('shows the actual cross-month interval and hides empty-day zeros while keepin }) it('renders server type counts instead of individual event cards and opens exact date/type details', async () => { const wrapper = render() - expect(wrapper.findAll('.work-day')).toHaveLength(42) + expect(wrapper.findAll('.work-day')).toHaveLength(35) const day = wrapper.get('[data-date="2026-10-15"]') expect(day.get('.day-heading').text()).toContain('2 项') await day.get('[data-type="DELIVERY"]').trigger('click') diff --git a/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue b/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue index beef2652..9ad0cf9c 100644 --- a/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue +++ b/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue @@ -2,7 +2,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' -import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse' +import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions, type FbaAppointmentDto } from '@/modules/overseas-warehouse' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -11,7 +11,7 @@ import type { SourceReference } from './workApi' import { rejectedCommand, useWorkDraft } from './useWorkDraft' import { workReason } from './workPresentation' const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>() -const emit = defineEmits<{ saved: [] }>() +const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>() const access = useAccessPreviewStore(), preview = ref(null) const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form)) const loading = ref(false), saving = ref(false), error = ref(null), saveError = ref(null) @@ -35,7 +35,7 @@ async function save() { try { const result = await createSourceAppointment(pending.value) pending.value = null; baseline.value = JSON.stringify(form) - ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved') + ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved', result) } catch (cause) { saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求') if (rejectedCommand(cause)) { pending.value = null; await load() } diff --git a/src/modules/appointment-calendar/calendar.test.ts b/src/modules/appointment-calendar/calendar.test.ts index bfd0c581..3da14ba4 100644 --- a/src/modules/appointment-calendar/calendar.test.ts +++ b/src/modules/appointment-calendar/calendar.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest' import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar' describe('appointment calendar', () => { - it('shows six complete Monday-first weeks across year boundaries', () => { - const dates = calendarDates('2027-01-15', 'month') - expect(dates).toHaveLength(42) - expect(dates[0]).toBe('2026-12-28') - expect(dates.at(-1)).toBe('2027-02-07') - expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08') + it.each([ + ['2026-10-19', '2026-09-28', '2026-11-02', 35], + ['2027-01-15', '2026-12-28', '2027-02-01', 35], + ['2027-02-15', '2027-02-01', '2027-03-01', 28], + ['2026-08-15', '2026-07-27', '2026-09-07', 42], + ] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => { + const dates = calendarDates(anchor, 'month') + expect(dates).toHaveLength(count) + expect(dates[0]).toBe(from) + expect(dates.at(-1)).toBe(addDays(to, -1)) + expect(calendarRange(anchor, 'month')).toEqual({ from, to }) }) it('moves by a week in week mode instead of following the prototype month-navigation bug', () => { diff --git a/src/modules/appointment-calendar/calendar.ts b/src/modules/appointment-calendar/calendar.ts index 0c4257fc..e6c2ba3d 100644 --- a/src/modules/appointment-calendar/calendar.ts +++ b/src/modules/appointment-calendar/calendar.ts @@ -29,16 +29,19 @@ export function addDays(value: string, days: number): string { export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } { const date = parseDate(anchor) + const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12) if (mode === 'month') date.setDate(1) date.setDate(date.getDate() - (date.getDay() + 6) % 7) const from = dateKey(date) - // A half-open interval also works directly with database timestamps. - return { from, to: addDays(from, mode === 'month' ? 42 : 7) } + // 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。 + return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) } } export function calendarDates(anchor: string, mode: CalendarMode): string[] { - const { from } = calendarRange(anchor, mode) - return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index)) + const { from, to } = calendarRange(anchor, mode) + const dates: string[] = [] + for (let date = from; date < to; date = addDays(date, 1)) dates.push(date) + return dates } export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string { diff --git a/src/modules/appointment-calendar/planningApi.test.ts b/src/modules/appointment-calendar/planningApi.test.ts index 803bdf4c..d25f0444 100644 --- a/src/modules/appointment-calendar/planningApi.test.ts +++ b/src/modules/appointment-calendar/planningApi.test.ts @@ -1,6 +1,6 @@ import { expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi' +import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi' vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: { data: T }) => value.data })) it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => { @@ -20,6 +20,15 @@ it('keeps basic container listing on its own endpoint rather than requiring impa expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined }) expect(result.total).toBe(0) }) +it('uses authoritative destination options and sends the selected destination to server pagination', async () => { + vi.mocked(http.get).mockClear() + vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']}) + expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8']) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined}) + vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}}) + await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'}) + expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined}) +}) it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => { vi.mocked(http.get).mockClear() vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}}) diff --git a/src/modules/appointment-calendar/planningApi.ts b/src/modules/appointment-calendar/planningApi.ts index 88f6f007..77762ff3 100644 --- a/src/modules/appointment-calendar/planningApi.ts +++ b/src/modules/appointment-calendar/planningApi.ts @@ -31,7 +31,7 @@ export interface PlanningCargo { mixedDestinations: boolean } export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number } -export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) { +export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) { const response = await http.get, ApiEnvelope>( '/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal }, ) @@ -39,6 +39,12 @@ export async function listPlanningCargo(query: { page: number; size: number; key return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) } } +export async function listPlanningDestinations(signal?: AbortSignal) { + return unwrapApiResponse(await http.get, ApiEnvelope>( + '/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal }, + )) +} + export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING' export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string } export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record } diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts index 7e197daa..ee48eb55 100644 --- a/src/styles/adminDensity.test.ts +++ b/src/styles/adminDensity.test.ts @@ -199,7 +199,8 @@ describe('approved Admin density ownership', () => { expect(declarations('.detail-events')['overflow-y']).toBe('auto') expect(declarations('.detail-event dd')['overflow-wrap']).toBe('anywhere') expect(panel.template!.content).toContain('.slice(0, 3)') - expect(panel.template!.content).not.toContain('新增预约') + expect(panel.template!.content).toContain('v-if="canCreate"') + expect(panel.template!.content).toContain('新增预约') }) it('shares list status and category tab styles across registered list consumers', () => { for (const file of [