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 @@
+
+
+
+
+
+
+
{{ day }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询可预约货物
+
+ 已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物
+
+
+
{{ editing.selectedCount }} 票货物 · 入仓码:{{ editing.items.map(item => item.inboundCode).join('、') || '—' }}
+
+
+
+
+
+
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],