From 2998c620999247bd7af69dfe6e0749072f14a038 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 11:01:41 +0800 Subject: [PATCH 1/9] feat: add appointment calendar and cross-customer booking --- AGENTS.md | 1 + src/app/router/index.ts | 2 + src/core/navigation/adminNavigation.test.ts | 5 +- src/core/navigation/adminNavigation.ts | 8 + .../AppointmentCalendarPanel.test.ts | 37 +++ .../AppointmentCalendarPanel.vue | 162 ++++++++++++ .../AppointmentCalendarView.test.ts | 69 +++++ .../AppointmentCalendarView.vue | 249 ++++++++++++++++++ .../appointment-calendar/PROTOTYPE_MATRIX.md | 19 ++ src/modules/appointment-calendar/api.ts | 28 ++ .../appointment-calendar/calendar.test.ts | 27 ++ src/modules/appointment-calendar/calendar.ts | 45 ++++ src/modules/appointment-calendar/routes.ts | 8 + .../api/inboundOrders.test.ts | 29 +- .../overseas-warehouse/api/inboundOrders.ts | 19 +- src/modules/overseas-warehouse/index.ts | 6 + .../pages/InboundOrdersView.vue | 26 +- .../pages/ReturnInventoryManagementView.vue | 20 +- .../pages/prototypeContract.test.ts | 4 +- src/modules/overseas-warehouse/routes.test.ts | 1 + 20 files changed, 727 insertions(+), 38 deletions(-) create mode 100644 src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts create mode 100644 src/modules/appointment-calendar/AppointmentCalendarPanel.vue create mode 100644 src/modules/appointment-calendar/AppointmentCalendarView.test.ts create mode 100644 src/modules/appointment-calendar/AppointmentCalendarView.vue create mode 100644 src/modules/appointment-calendar/PROTOTYPE_MATRIX.md create mode 100644 src/modules/appointment-calendar/api.ts create mode 100644 src/modules/appointment-calendar/calendar.test.ts create mode 100644 src/modules/appointment-calendar/calendar.ts create mode 100644 src/modules/appointment-calendar/routes.ts create mode 100644 src/modules/overseas-warehouse/index.ts diff --git a/AGENTS.md b/AGENTS.md index aba261cf..57371fb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | +| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | | FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | | PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | diff --git a/src/app/router/index.ts b/src/app/router/index.ts index de5e877a..9fe2ee6e 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -33,6 +33,7 @@ import { overseasBusinessRoutes } from '@/modules/overseas-business/routes' import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes' import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes' +import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes' const router = createRouter({ history: createWebHistory(), @@ -79,6 +80,7 @@ const router = createRouter({ ...iamRoutes, ...pdaReleaseRoutes, ...sailingCalendarRoutes, + ...appointmentCalendarRoutes, { path: '403', name: 'forbidden', diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 878b744b..b1dbf3b6 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -22,6 +22,7 @@ describe('adminNavigation', () => { { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] }, { title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] }, + { title: '预约管理', children: ['预约列表'] }, { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, @@ -29,8 +30,8 @@ describe('adminNavigation', () => { { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) - expect(visible).toHaveLength(11) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51) + expect(visible).toHaveLength(12) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 93a6a991..4fd3b351 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -118,6 +118,14 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' }, ], }, + { + key: 'appointment-management', + title: '预约管理', + icon: CalendarDays, + children: [ + { key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment:list' }, + ], + }, { key: 'vehicle-management', title: '车辆管理', diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts new file mode 100644 index 00000000..4870fa91 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' + +describe('appointment calendar interactions', () => { + const render = () => mount(AppointmentCalendarPanel, { + props: { initialDate: '2026-09-24', canCreate: true, events: [ + { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true }, + ] }, + global: { stubs: { ElButton: { template: '' } }, directives: { loading: {} } }, + }) + + it('selects an already loaded date without requesting data again', async () => { + const wrapper = render() + await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click') + expect(wrapper.emitted('range')).toBeUndefined() + expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约') + }) + + it('requests exactly one range for a view switch and navigates seven days', async () => { + const wrapper = render() + await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click') + expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]]) + await wrapper.get('[aria-label="下一周"]').trigger('click') + expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }]) + }) + + it('uses the selected day for creation and emits edit/delete only from detail actions', async () => { + const wrapper = render() + await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click') + expect(wrapper.emitted('create')).toEqual([['2026-09-24']]) + await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click') + await wrapper.findAll('button').find(button => button.text() === '删除')!.trigger('click') + expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' }) + expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' }) + }) +}) diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue new file mode 100644 index 00000000..b6308199 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts new file mode 100644 index 00000000..60a884b4 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -0,0 +1,69 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppointmentCalendarView from './AppointmentCalendarView.vue' +import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse' +import { listArrivalCalendar } from './api' + +vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() })) + +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('@/modules/overseas-warehouse', () => ({ + createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(), + listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), + overseasWarehousePermissions: { inboundOrderAppointment: 'write' }, +})) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) + +const candidates = [ + { lineId: '9007199254740993', customerId: 'A', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' }, + { lineId: '9007199254740994', customerId: 'B', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' }, +] +function render() { + return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: { + AdminPage: { template: '
' }, + AppointmentCalendarPanel: { template: '' }, + AppDialog: { props: ['modelValue'], template: '
' }, + ElForm: { template: '
' }, + ElFormItem: { template: '' }, + ElInput: { props: ['modelValue', 'placeholder'], template: '' }, + ElDatePicker: { props: ['modelValue'], template: '' }, + ElButton: { template: '' }, + ElSelect: true, ElOption: true, + DataTable: { props: ['data'], template: '
' }, + LoadErrorState: true, ApiErrorMeta: true, + } } }) +} + +describe('appointment calendar data integration', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([]) + vi.mocked(listArrivalCalendar).mockResolvedValue([]) + vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 }) + vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) + }) + + it('creates one shared appointment for two customers without sending a fabricated header customer', async () => { + const wrapper = render() + await flushPromises() + expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1) + expect(listArrivalCalendar).toHaveBeenCalledTimes(1) + expect(listInboundOrders).not.toHaveBeenCalled() + await wrapper.get('[data-create]').trigger('click') + await wrapper.get('input[placeholder="例如 ONT8"]').setValue('ONT8') + await wrapper.findAll('button').find(button => button.text() === '查询可预约货物')!.trigger('click') + await flushPromises() + await wrapper.get('[data-select]').trigger('click') + await wrapper.get('input[placeholder="请输入预约编号"]').setValue('APT-AB') + await wrapper.findAll('[data-date]')[2]!.setValue('2026-10-09T07:00:00') + await wrapper.get('[data-save]').trigger('click') + await flushPromises() + expect(createFbaAppointment).toHaveBeenCalledTimes(1) + expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({ + appointmentNo: 'APT-AB', lineIds: ['9007199254740993', '9007199254740994'], + })) + expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId') + expect(listInboundOrders).toHaveBeenCalledTimes(1) + expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue new file mode 100644 index 00000000..73054887 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md new file mode 100644 index 00000000..37f47e98 --- /dev/null +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -0,0 +1,19 @@ +# 预约管理交互矩阵 + +原型:用户提供 `C:/Users/BY-OY/Desktop/新版.html` 的 `page-appointment-calendar`、`modal-appointment-edit`、`renderAppointmentCalendar`、`renderAppointmentDay`、`openAppointmentModal`、`deleteAppointment`,以及本对话三张截图。 + +用户确认:同一目的仓允许多个客户货物统一预约、统一送仓,不能按客户拆预约。客户隔离保留在货物明细。 + +| 页面/操作 | 原型承载与布局 | 实现约束 | +|---|---|---| +| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 | +| 新增预约 | 居中双列表单,原型最大宽度760px | 保留居中双列表单;为跨客户货物选择扩至1080px,在同弹框下方分页选择真实入仓货物 | +| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 | +| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 | +| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 | +| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 | +| 多条安排 | 日历标签、右侧明细 | 日格限制显示数量并提示剩余条目,右侧全部展示 | + +预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选只在用户点击查询时分页读取,跨页选择保留,重新查询清空。 + +视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。 diff --git a/src/modules/appointment-calendar/api.ts b/src/modules/appointment-calendar/api.ts new file mode 100644 index 00000000..9affcc1f --- /dev/null +++ b/src/modules/appointment-calendar/api.ts @@ -0,0 +1,28 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface ArrivalCalendarEntry { + containerBookingId: string + bookingNo: string + containerNo?: string | null + vesselName?: string | null + voyageNo?: string | null + destinationPortName?: string | null + destinationWarehouseCode?: string | null + arrivalAt: string + arrivalType: 'ACTUAL' | 'ESTIMATED' +} + +export async function listArrivalCalendar(query: { from: string; to: string }, signal?: AbortSignal) { + const response = await http.get, ApiEnvelope>( + '/platform/v1/port-schedules/arrival-calendar', { params: query, signal }, + ) + return unwrapApiResponse(response) +} + +export async function deleteAppointment(appointmentNo: string, version: number) { + const response = await http.delete, ApiEnvelope>( + `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`, + { params: { version } }, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/appointment-calendar/calendar.test.ts b/src/modules/appointment-calendar/calendar.test.ts new file mode 100644 index 00000000..bfd0c581 --- /dev/null +++ b/src/modules/appointment-calendar/calendar.test.ts @@ -0,0 +1,27 @@ +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('moves by a week in week mode instead of following the prototype month-navigation bug', () => { + expect(moveCalendar('2026-09-24', 'week', 1)).toBe('2026-10-01') + expect(calendarDates('2026-09-27', 'week')).toEqual([ + '2026-09-21', '2026-09-22', '2026-09-23', '2026-09-24', + '2026-09-25', '2026-09-26', '2026-09-27', + ]) + }) + + it('clamps month-end selection and handles leap days', () => { + expect(moveCalendar('2028-01-31', 'month', 1)).toBe('2028-02-29') + expect(moveCalendar('2027-03-31', 'month', -1)).toBe('2027-02-28') + expect(addDays('2028-02-28', 1)).toBe('2028-02-29') + expect(() => addDays('2027-02-29', 1)).toThrow('无效的日历日期') + }) +}) diff --git a/src/modules/appointment-calendar/calendar.ts b/src/modules/appointment-calendar/calendar.ts new file mode 100644 index 00000000..c457c48e --- /dev/null +++ b/src/modules/appointment-calendar/calendar.ts @@ -0,0 +1,45 @@ +export type CalendarMode = 'month' | 'week' + +/** Calendar dates are warehouse-local dates, never UTC conversions. */ +export function dateKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +function parseDate(value: string): Date { + const date = new Date(`${value}T12:00:00`) + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(date.getTime()) || dateKey(date) !== value) { + throw new Error('无效的日历日期') + } + return date +} + +export function addDays(value: string, days: number): string { + const date = parseDate(value) + date.setDate(date.getDate() + days) + return dateKey(date) +} + +export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } { + const date = parseDate(anchor) + 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) } +} + +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)) +} + +export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string { + if (mode === 'week') return addDays(anchor, offset * 7) + const date = parseDate(anchor) + const day = date.getDate() + date.setDate(1) + date.setMonth(date.getMonth() + offset) + const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + date.setDate(Math.min(day, lastDay)) + return dateKey(date) +} diff --git a/src/modules/appointment-calendar/routes.ts b/src/modules/appointment-calendar/routes.ts new file mode 100644 index 00000000..b141884d --- /dev/null +++ b/src/modules/appointment-calendar/routes.ts @@ -0,0 +1,8 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const appointmentCalendarRoutes = [{ + path: 'appointment-calendar', + name: 'appointment-calendar', + component: () => import('./AppointmentCalendarView.vue'), + meta: { title: '预约列表', showPageHeading: false, permission: 'platform:appointment:list' }, +}] satisfies RouteRecordRaw[] diff --git a/src/modules/overseas-warehouse/api/inboundOrders.test.ts b/src/modules/overseas-warehouse/api/inboundOrders.test.ts index 2dc8e355..8530829b 100644 --- a/src/modules/overseas-warehouse/api/inboundOrders.test.ts +++ b/src/modules/overseas-warehouse/api/inboundOrders.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment } from './inboundOrders' +import { createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment } from './inboundOrders' vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) @@ -11,16 +11,32 @@ describe('inbound order overseas API', () => { vi.mocked(http.put).mockReset() }) + it('loads a calendar range in one request and supports cancelling obsolete requests', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) + const controller = new AbortController() + const query = { from: '2026-08-31', to: '2026-10-12' } + expect(await listFbaAppointmentCalendar(query, controller.signal)).toEqual([]) + expect(http.get).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/calendar', { + params: query, signal: controller.signal, + }) + }) + + it('does not turn calendar business errors into an empty successful calendar', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '500', message: '日历查询失败', data: null }) + await expect(listFbaAppointmentCalendar({ from: '2026-09-01', to: '2026-10-01' })).rejects.toThrow() + }) + it('loads appointment details and updates the existing appointment without replacing its lines', async () => { - const detail = { id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] } + const detail = { version: 0, id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] } vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: detail }) - await getFbaAppointment('190000000000000001', 'APT/1') - await updateFbaAppointment('APT/1', { customerId: '190000000000000001', appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' }) + await getFbaAppointment('APT/1') + await updateFbaAppointment('APT/1', { version: detail.version, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' }) - expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', { params: { customerId: '190000000000000001' } }) - expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ customerId: '190000000000000001' })) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1') + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.not.objectContaining({ customerId: expect.anything() })) }) it('loads the paginated list across all customers without a customer scope', async () => { @@ -34,7 +50,6 @@ describe('inbound order overseas API', () => { it('preserves selected line ids as strings when creating an appointment', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', appointmentNo: 'APT-1', appointmentStatus: 'SUCCESS', selectedCount: 1, fbaWarehouseCode: 'ONT8' } }) const payload = { - customerId: '190000000000000001', lineIds: ['190000000000000099'], appointmentNo: 'APT-1', appointmentAt: '2026-08-20T10:30:00', diff --git a/src/modules/overseas-warehouse/api/inboundOrders.ts b/src/modules/overseas-warehouse/api/inboundOrders.ts index 00319f71..9119ca13 100644 --- a/src/modules/overseas-warehouse/api/inboundOrders.ts +++ b/src/modules/overseas-warehouse/api/inboundOrders.ts @@ -54,29 +54,37 @@ export interface InboundOrderSummaryDto { } export interface BatchFbaAppointmentPayload { - customerId: string lineIds: string[] appointmentNo: string appointmentAt: string + appointmentEndAt?: string | null expectedLoadingAt: string appointmentStatus: 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' appointmentRemark?: string } export interface FbaAppointmentDto { + version: number id: string - customerId: string appointmentNo: string appointmentAt: string + appointmentEndAt?: string | null expectedLoadingAt: string appointmentStatus: string appointmentRemark?: string | null selectedCount: number fbaWarehouseCode: string - items: Array<{ id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }> + items: Array<{ customerId: string; id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }> } -export type UpdateFbaAppointmentPayload = Omit +export type UpdateFbaAppointmentPayload = Omit & { version: number } + +export async function listFbaAppointmentCalendar(query: { from: string; to: string }, signal?: AbortSignal) { + const response = await http.get, ApiEnvelope>( + '/platform/v1/last-leg/inbound-plans/fba-appointments/calendar', { params: query, signal }, + ) + return unwrapApiResponse(response) +} export async function listInboundOrders(query: InboundOrderQuery) { const response = await http.get>, ApiEnvelope>>( @@ -92,10 +100,9 @@ export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) return unwrapApiResponse(response) } -export async function getFbaAppointment(customerId: string, appointmentNo: string) { +export async function getFbaAppointment(appointmentNo: string) { const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`, - { params: { customerId } }, ) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/index.ts b/src/modules/overseas-warehouse/index.ts new file mode 100644 index 00000000..4f56418b --- /dev/null +++ b/src/modules/overseas-warehouse/index.ts @@ -0,0 +1,6 @@ +export { + createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, + listInboundOrders, updateFbaAppointment, +} from './api/inboundOrders' +export type { FbaAppointmentDto, BatchFbaAppointmentPayload, InboundOrderSummaryDto } from './api/inboundOrders' +export { overseasWarehousePermissions } from './permissions' diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue index 52b178e8..9c7e592d 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue @@ -50,10 +50,10 @@ const smartDispatchOpen = ref(false) const appointmentLoading = ref(false) const appointmentMode = ref<'create' | 'edit'>('create') const editingAppointmentNo = ref('') -const editingCustomerId = ref('') const appointmentSelectedCount = ref(0) const appointmentFbaWarehouse = ref('—') -const appointmentForm = reactive({ appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' }) +const appointmentVersion = ref(0) +const appointmentForm = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' }) const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约') const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref([]) @@ -61,7 +61,7 @@ function selectableRow(source: object) { const row = source as InboundOrderListItem if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false const anchor = selectedRows.value[0] - return !anchor || (anchor.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse) + return !anchor || anchor.fbaWarehouse === row.fbaWarehouse } const columns: DataTableColumn[] = [ @@ -117,7 +117,6 @@ function onSelectionChange(selection: object[]) { selectedRows.value = selection function selectionError() { if (!selectedRows.value.length) return '请至少选择 1 条入库单' if (selectedRows.value.some((row) => !row.fbaWarehouse || row.fbaWarehouse === '—')) return '所选入库单缺少 FBA 仓库信息' - if (new Set(selectedRows.value.map((row) => row.customerId)).size !== 1) return '所选入库单必须属于同一客户' if (new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1) return '所选入库单必须属于同一 FBA 仓库' return '' } @@ -125,9 +124,10 @@ function selectionError() { function openAppointment() { const error = selectionError() if (error) { ElMessage.warning(error); return } - appointmentMode.value = 'create'; editingAppointmentNo.value = ''; editingCustomerId.value = '' + appointmentMode.value = 'create'; editingAppointmentNo.value = '' appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—' - Object.assign(appointmentForm, { appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' }) + appointmentVersion.value = 0 + Object.assign(appointmentForm, { appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' }) mutationError.value = null appointmentOpen.value = true } @@ -139,13 +139,14 @@ function openSmartDispatch() { async function openAppointmentEdit(source: object) { const row = source as InboundOrderListItem if (!row.appointmentNo || row.appointmentNo === '—') return - appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo; editingCustomerId.value = row.customerId + appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true try { - const detail = await getFbaAppointment(row.customerId, row.appointmentNo) + const detail = await getFbaAppointment(row.appointmentNo) appointmentSelectedCount.value = detail.selectedCount appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse - Object.assign(appointmentForm, { appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' }) + appointmentVersion.value = detail.version + Object.assign(appointmentForm, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' }) } catch (caught) { appointmentOpen.value = false mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试') @@ -157,9 +158,9 @@ async function submitAppointment() { if (!appointmentForm.appointmentNo.trim() || !appointmentForm.appointmentAt || !appointmentForm.expectedLoadingAt) { ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return } saving.value = true; mutationError.value = null try { - const common = { appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined } - if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { customerId: editingCustomerId.value, ...common }) - else await createFbaAppointment({ customerId: selectedRows.value[0].customerId, lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common }) + const common = { appointmentEndAt: appointmentForm.appointmentEndAt || null, appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined } + if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { ...common, version: appointmentVersion.value }) + else await createFbaAppointment({ lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common }) appointmentOpen.value = false; selectedRows.value = []; ElMessage.success('FBA 预约保存成功'); await load() } catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试') } finally { saving.value = false } @@ -210,6 +211,7 @@ onMounted(() => { void load() }) + diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue index 3e53d40c..2b7d7d49 100644 --- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue @@ -166,7 +166,7 @@ async function reviewPlan(plan: Record) { } async function cancelPlan(plan: Record) { try { - await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning' }); + await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning', customClass: 'admin-density-confirm' }); await cancelReturnPackingPlan(String(plan.planNo)); ElMessage.success('计划已取消,库存已释放'); await load(); } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '取消失败'); } } @@ -567,7 +567,7 @@ const tableColumns = computed(() => > padding: 7px 10px; border-bottom: 1px solid var(--cc-border); } -.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: 12px; } +.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-caption); } .packing-sku-row:last-of-type { border-bottom: 0; } .packing-sku-row > span { color: var(--cc-muted); - font-size: 12px; + font-size: var(--admin-font-caption); white-space: nowrap; } .packing-sku-row :deep(.el-input-number) { @@ -1014,7 +1014,7 @@ const tableColumns = computed(() => padding: 0 7px; } .packing-sku-row :deep(.el-input__inner) { - font-size: 12px; + font-size: var(--admin-font-caption); text-align: center; } .packing-box-list { @@ -1041,7 +1041,7 @@ const tableColumns = computed(() => top: 0; z-index: 1; color: var(--cc-muted); - font-size: 12px; + font-size: var(--admin-font-caption); background: var(--cc-surface-subtle, #f8fafc); border-bottom: 1px solid var(--cc-border); } @@ -1055,20 +1055,20 @@ const tableColumns = computed(() => .packing-box-list-row > span, .packing-box-items { color: var(--cc-muted); - font-size: 12px; + font-size: var(--admin-font-caption); } .packing-box-items { display: flex; flex-wrap: wrap; gap: 6px; } -.return-nos-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; } +.return-nos-count { color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; } .packing-form :deep(.el-alert) { margin-top: 2px; padding: 8px 12px; } .packing-form :deep(.el-alert__title) { - font-size: 12px; + font-size: var(--admin-font-caption); } @media (max-width: 700px) { .packing-box-list { overflow-x: auto; } diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index 11e51888..a803c8c3 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -26,6 +26,7 @@ describe('overseas warehouse first-batch prototype contract', () => { const expected = new Map([ ['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'], ['InboundOrdersView.vue', 'overseas-inbound-orders-query'], + ['CargoDiscardsView.vue', 'overseas-cargo-discards-query'], ['DispatchesView.vue', 'overseas-dispatches-query'], ['DeliveryOrdersView.vue', 'overseas-delivery-orders-query'], ['CustomerVisitsView.vue', 'overseas-customer-visits-query'], @@ -211,7 +212,8 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(value).toContain('createFbaAppointment') expect(value).toContain('预计装车时间') expect(value).toContain('所选入库单必须属于同一 FBA 仓库') - expect(value).toContain('anchor.customerId === row.customerId') + expect(value).not.toContain('anchor.customerId === row.customerId') + expect(value).toContain('anchor.fbaWarehouse === row.fbaWarehouse') expect(value).toContain('预约') expect(value).toContain('智能排车') expect(value).toContain('type="primary" @click="openSmartDispatch"') diff --git a/src/modules/overseas-warehouse/routes.test.ts b/src/modules/overseas-warehouse/routes.test.ts index 6b924228..328c2fdb 100644 --- a/src/modules/overseas-warehouse/routes.test.ts +++ b/src/modules/overseas-warehouse/routes.test.ts @@ -8,6 +8,7 @@ describe('overseas warehouse routes', () => { ['overseas-warehouse/last-mile-orders', permission.lastMileOrderList], ['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList], ['overseas-warehouse/inbound-orders', permission.inboundOrderList], + ['overseas-warehouse/cargo-discards', permission.discardList], ['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList], ['overseas-warehouse/dispatches', permission.dispatchList], ['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList], From 6e669a1377b4d94121bfd379ab78fb7a6d4babc5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 11:11:20 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ContainerCustomsPanel.test.ts | 3 ++- .../components/ContainerCustomsPanel.vue | 15 ++++++++++----- .../components/ContainerOverviewPanel.vue | 14 ++++++-------- .../pages/ContainerDetailView.vue | 8 ++++++-- src/styles/adminDensity.test.ts | 11 +++++++++++ 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index e6f4e900..6f2c83a2 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -116,7 +116,8 @@ describe('ContainerCustomsPanel', () => { it('keeps READY_TO_START read-only while exposing source file name and download', async () => { const wrapper = await render('READY_TO_START') expect(wrapper.text()).toContain('开始报关') - expect(wrapper.text()).toContain('实际箱数 12') + expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数') + expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12') expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料') expect(wrapper.text()).toContain('报关行回执') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 041c9db4..65ea7b45 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -291,7 +291,7 @@ onMounted(load)
-

整柜报关

客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。

+

整柜报关

资料、回执与完税文件按柜管理。

{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }} 导出买单 XLSX @@ -307,8 +307,13 @@ onMounted(load)