diff --git a/AGENTS.md b/AGENTS.md
index 231d6785..7e8463b6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,7 +3,7 @@
## 项目定位
- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。
-- 当前范围包括工作台、客户管理、头程订单、仓储与国内出仓协同、财务管理、内部用户、角色管理和菜单权限。
+- 当前范围包括工作台、客户管理、头程订单、仓储与国内出仓协同、财务管理、四期海外接柜入库与库存管理、内部用户、角色管理和菜单权限。
- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。
## 技术基线
@@ -58,11 +58,15 @@
| 财务账户、费用来源、应收统计、对账单、收付款与成本 | `modules/finance/`;应收统计从 `pages/FinanceReceivableStatisticsView.vue`、`api/finance.ts` 进入 | `finance/`;应收统计只汇总指定生成月份的 CNY 正式应收结算单,导出使用 `FINANCE_RECEIVABLE_STATISTICS` 进入 File 下载中心;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` |
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
| 预装详情、装柜清单与柜次费用 | `modules/loading-lists/pages/PreloadPlanDetailView.vue`、`modules/loading-lists/components/LoadingListDetailDrawer.vue`、`modules/loading-lists/components/ContainerFeesPanel.vue`、`modules/loading-lists/api/containerFees.ts` | `firstleg/loading/`、`firstleg` 柜次费用、`finance/` 成本分摊、`file/`、`warehouse/` |
-| 海运订舱与船期 | `modules/ocean-bookings/`、`modules/port-schedules/`;订舱状态、柜型需求和 API 分别从 `status.ts`、`containerRequirements.ts`、`api/platformOceanBookings.ts` 进入 | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/`;订舱仅有 `DRAFT`、`RELEASED`,草稿可编辑/删除,放舱后永久只读;创建和放舱只保存订舱聚合与柜型需求,不生成真实柜号或货柜分配。渠道来自起运仓当前 ACTIVE 价格版本;订舱代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml` |
+| 三期海运订舱与出仓前船期资料 | `modules/ocean-bookings/`、`modules/port-schedules/`;订舱状态、柜型需求和 API 分别从 `status.ts`、`containerRequirements.ts`、`api/platformOceanBookings.ts` 进入 | `firstleg/oceanbooking/`、`firstleg/portschedule/` 的 `PlatformPortScheduleController`;订舱仅有 `DRAFT`、`RELEASED`,草稿可编辑/删除,放舱后永久只读;创建和放舱只保存订舱聚合与柜型需求,不生成真实柜号或货柜分配。渠道来自起运仓当前 ACTIVE 价格版本;订舱代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml`。`CarrierScheduleQuery` 及柜子出仓后的船节点查询归四期 |
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/`;承运商联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/truck-appointments-v1.yaml` |
| 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/`;报关代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/customs-v1.yaml` |
| 国内出仓确认 | `modules/outbound-dispatch/` | `firstleg/outbound/dispatch/` |
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/`;Finance 导出联查 `finance/` 内部 Export Provider 与 File 导出定义 |
+| 四期海外仓接柜、入库与库内库存 | `modules/overseas-warehouse/`;先读 `../../docs/四期/README.md`,不得以 `src/prototype/` 作为业务实现 | 海外区域 API/服务;原型定向读取 `03-overseas-warehouse.html`。包含尾程订单、入库、换标、托盘、箱号、产品和库存,不包含海外仓出库及海外派送 |
+| 四期海外业务 | `modules/overseas-business/`;先读 `../../docs/四期/README.md` | 海外区域 API/服务;原型定向读取 `07-overseas-business.html`;包含采购订单、本土卡车和本土散板 |
+| 四期仓库与通用资料扩展 | 海外仓合作仓类型进入 `modules/warehouses/`;海外仓报价、汇率和车辆进入 `modules/overseas-master-data/` | 仓库类型联查 Backend `warehouse/`;海外报价、汇率和车辆联查海外区域 API/服务;原型定向读取 `10-general.html` |
+| 四期国内出仓后船节点定时查询 | 现有 `CarrierScheduleQuery` 整体归此范围;入口为 `modules/port-schedules/`、`modules/ocean-bookings/` 及货柜详情,先读 `../../docs/四期/README.md` | `firstleg/portschedule/`、`integration-service/`;从确认离开国内仓库后查询,展示最新节点、来源、观测/更新时间和历史并衔接目的港提柜;不归三期或海外派送 |
## Admin 全量验收快速索引
@@ -74,7 +78,7 @@
| 二期及三期履约 | `modules/inbounds/`、`warehouse-resources/`、`fba-pricing/`、`dimension-config/`、`fee-items/`、`unified-orders/`、`container-loading/`、`loading-lists/`、`ocean-bookings/`、`port-schedules/`、`trucking/`、`customs/`、`outbound-dispatch/` | 按真实服务端契约核对全链路状态、权限、异常和证据 |
| 平台公共运营 | `modules/suppliers/`、`domestic-containers/`、`audit/`、`notifications/`,客户运营指标复用 `customers/` | 核对公共作用域、列表/详情只读权限、写操作独立权限、审计脱敏和统计口径 |
-阶段文档入口(路径相对本文件所在目录):按验收业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 或 `../../docs/财务管理/` 的需求确认、契约和验收规则;进度、排期或验收状态问题再读取相应进度表和验收报告。
+阶段文档入口(路径相对本文件所在目录):全量验收按业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 或 `../../docs/财务管理/` 的需求确认、契约和验收规则;四期开发范围单独读取 `../../docs/四期/README.md`,不因进入开发就视为已纳入全量验收。进度、排期或验收状态问题再读取相应进度表和验收报告。
## 业务与安全约束
diff --git a/src/app/DomesticOutboundListStates.test.ts b/src/app/DomesticOutboundListStates.test.ts
index 90a99baa..470fa9e5 100644
--- a/src/app/DomesticOutboundListStates.test.ts
+++ b/src/app/DomesticOutboundListStates.test.ts
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
const pages = [
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
- ['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', '查看', '@retry="load"'],
+ ['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', "{{ row.status === 'DRAFT' ? '编辑' : '查看' }}", '@retry="load"'],
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '详情', '@retry="load"'],
] as const
diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts
index c3d18b1c..1eed235a 100644
--- a/src/app/layouts/AppLayout.test.ts
+++ b/src/app/layouts/AppLayout.test.ts
@@ -60,6 +60,7 @@ function mockViewport(isMobile: boolean) {
describe('AppLayout', () => {
beforeEach(() => {
+ window.localStorage.clear()
vi.mocked(ElMessageBox.confirm).mockReset()
mockViewport(false)
})
@@ -111,10 +112,16 @@ describe('AppLayout', () => {
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler)
})
- it('shows the authenticated identity without a role preview switcher', async () => {
+ it('shows the authenticated identity and the localhost full-access control', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
- expect(wrapper.text()).not.toContain('权限预览')
+ const accessToggle = wrapper.get('[role="switch"]')
+ expect(accessToggle.text()).toContain('本地全权限')
+ expect(accessToggle.text()).toContain('已关闭')
+ expect(accessToggle.attributes('aria-checked')).toBe('false')
+ await accessToggle.trigger('click')
+ expect(accessToggle.text()).toContain('已开启')
+ expect(accessToggle.attributes('aria-checked')).toBe('true')
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
diff --git a/src/app/layouts/AppLayout.vue b/src/app/layouts/AppLayout.vue
index 29c2e392..ff3b690a 100644
--- a/src/app/layouts/AppLayout.vue
+++ b/src/app/layouts/AppLayout.vue
@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
-import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
+import { Bell, FileText, LogOut, Mail, Menu, ShieldCheck } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
@@ -159,6 +159,19 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
+
+
状态历史
当前进度:{{ statusLabel(detail.status) }}
{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}{{ event.operatorName || '系统' }} · {{ event.reason }}
diff --git a/src/modules/overseas-business/CONTRACT_GAPS.md b/src/modules/overseas-business/CONTRACT_GAPS.md
new file mode 100644
index 00000000..af81597e
--- /dev/null
+++ b/src/modules/overseas-business/CONTRACT_GAPS.md
@@ -0,0 +1,14 @@
+# 海外业务待补契约
+
+当前仓库尚未提供海外区域 API Base、海外 Token 选择器或海外采购/本土运输正式端点。模块因此只冻结 TypeScript 请求/响应模型和页面结构,不创建临时 URL,不使用示例数据,也不模拟提交成功。
+
+后端接入前需冻结:
+
+- 海外 Gateway Base 与统一认证层的海外 Token 选择方式;
+- 采购订单、卡车订单、散板订单的列表、详情、创建与更新端点;
+- 分页信封、长整型 ID 字符串透传、金额/币种精度和时间格式;
+- 状态机、允许编辑状态、乐观锁版本、幂等键及错误码;
+- 客户、采购仓库等只读候选接口与权限;
+- 对应 `list/create/edit` 权限资源在海外服务端的鉴权落点。
+
+接入时应在统一双区域 HTTP/认证层完成 Base 与 Token 选择,业务页面不得自行读写 Token 或创建第二套登录。
diff --git a/src/modules/overseas-business/PROTOTYPE_MATRIX.md b/src/modules/overseas-business/PROTOTYPE_MATRIX.md
new file mode 100644
index 00000000..56040420
--- /dev/null
+++ b/src/modules/overseas-business/PROTOTYPE_MATRIX.md
@@ -0,0 +1,20 @@
+# 四期海外业务原型核对矩阵
+
+原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。
+
+| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
+|---|---|---|---|---|
+| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列 |
+| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
+| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
+| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
+| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
+| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
+| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
+| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
+| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
+| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
+| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
+| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
+
+删除、审核/确认和文件预览在三类正式页面中均未定义,本批不自行补充承载方式。
diff --git a/src/modules/overseas-business/api/contracts.ts b/src/modules/overseas-business/api/contracts.ts
new file mode 100644
index 00000000..bdb479aa
--- /dev/null
+++ b/src/modules/overseas-business/api/contracts.ts
@@ -0,0 +1,31 @@
+import type {
+ LocalLooseboardOrder,
+ LocalTransportOrder,
+ LocalTransportQuery,
+ OverseasBusinessPage,
+ OverseasPurchaseOrder,
+ OverseasPurchaseQuery,
+ SaveLocalLooseboardOrderRequest,
+ SaveLocalTransportOrderRequest,
+ UpdateOverseasPurchaseRequest,
+} from '../types'
+
+/**
+ * 海外区域 API 的前端边界。
+ *
+ * 当前仓库尚未冻结海外 API Base、Token 选择和端点路径,因此这里只保存
+ * 请求/响应形状,不创建 Axios 实例、不拼接临时 URL,也不提供模拟实现。
+ */
+export interface OverseasBusinessApi {
+ listPurchases(query: OverseasPurchaseQuery): Promise>
+ getPurchase(purchaseId: string): Promise
+ updatePurchase(purchaseId: string, payload: UpdateOverseasPurchaseRequest): Promise
+ listTrucks(query: LocalTransportQuery): Promise>
+ getTruck(orderId: string): Promise
+ createTruck(payload: SaveLocalTransportOrderRequest): Promise
+ updateTruck(orderId: string, payload: SaveLocalTransportOrderRequest): Promise
+ listLooseboards(query: LocalTransportQuery): Promise>
+ getLooseboard(orderId: string): Promise
+ createLooseboard(payload: SaveLocalLooseboardOrderRequest): Promise
+ updateLooseboard(orderId: string, payload: SaveLocalLooseboardOrderRequest): Promise
+}
diff --git a/src/modules/overseas-business/components/ContractPendingNotice.vue b/src/modules/overseas-business/components/ContractPendingNotice.vue
new file mode 100644
index 00000000..bb2132d3
--- /dev/null
+++ b/src/modules/overseas-business/components/ContractPendingNotice.vue
@@ -0,0 +1,14 @@
+
+
+
+
+ {{ domain }}海外区域接口待接入
+ 页面已按四期原型冻结字段与承载方式;海外 API Base、Token 选择和正式端点补齐前,不展示演示数据,也不会模拟查询或写入成功。
+
+
+
+
diff --git a/src/modules/overseas-business/contract.test.ts b/src/modules/overseas-business/contract.test.ts
new file mode 100644
index 00000000..1d146885
--- /dev/null
+++ b/src/modules/overseas-business/contract.test.ts
@@ -0,0 +1,53 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/overseas-business/${path}`), 'utf8')
+
+describe('overseas business pending contract pages', () => {
+ it('keeps operation columns first and renders no sample records', () => {
+ const purchase = read('pages/PurchaseOrdersView.vue')
+ const transport = read('pages/TransportOrdersView.vue')
+ const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
+ const transportColumns = transport.slice(transport.indexOf('const baseColumns'))
+ expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
+ expect(transportColumns.indexOf("key: 'actions'")).toBeLessThan(transportColumns.indexOf("key: 'orderNo'"))
+ expect(purchase).toContain('const rows = ref([])')
+ expect(transport).toContain('const rows = ref>([])')
+ expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
+ })
+
+ it('gives every formal list a stable query preference key without sharing transport preferences', () => {
+ const purchase = read('pages/PurchaseOrdersView.vue')
+ const transport = read('pages/TransportOrdersView.vue')
+ const purchaseToolbar = purchase.match(/]*\/>/)?.[0]
+ const transportToolbar = transport.match(/]*\/>/)?.[0]
+
+ expect(purchaseToolbar).toContain('preference-key="overseas-business-purchases-query"')
+ expect(transportToolbar).toContain(':preference-key="queryPreferenceKey"')
+ expect(transport).toContain("'overseas-business-truck-query'")
+ expect(transport).toContain("'overseas-business-looseboard-query'")
+ })
+
+ it('does not invent an overseas endpoint or success fallback', () => {
+ const apiContract = read('api/contracts.ts')
+ const allPages = ['PurchaseOrdersView.vue', 'PurchaseOrderDetailView.vue', 'PurchaseOrderEditView.vue', 'TransportOrdersView.vue', 'TransportOrderFormView.vue']
+ .map((file) => read(`pages/${file}`)).join('\n')
+ expect(apiContract).not.toContain("from '@/shared/api/http'")
+ expect(apiContract).not.toMatch(/\/platform\/v1|\/overseas\/v1|axios\.create/)
+ expect(allPages).not.toMatch(/ElMessage\.success|模拟成功|提交成功/)
+ expect(allPages).toContain('海外区域写端点待冻结')
+ expect(allPages).toContain('海外区域更新端点待冻结')
+ })
+
+ it('keeps prototype-backed forms as routes with validation and unsaved-work registration', () => {
+ const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
+ const transportForm = read('pages/TransportOrderFormView.vue')
+ expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
+ expect(purchaseEdit).toContain('出库量不能大于入库量')
+ expect(transportForm).toContain('useUnsavedWorkRegistration')
+ expect(transportForm).toContain("message: '请输入起运地'")
+ expect(transportForm).toContain("message: '请输入目的地'")
+ expect(transportForm).toContain('disabled>
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ArrowLeft } from '@lucide/vue'
+import AdminPage from '@/shared/components/AdminPage.vue'
+import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
+import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
+import ContractPendingNotice from '../components/ContractPendingNotice.vue'
+import type { InventoryTransaction } from '../types'
+
+const route = useRoute()
+const router = useRouter()
+const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
+const fields = ['客户名称', '采购仓库', '采购品类', '商品名称', '商品规格', '商品链接']
+const stockFields = ['入库量', '出库量', '库存量', '库存状态']
+const orderFields = ['订单编号', '采购数量', '预算单价', '预算总价', '订单状态', '联系人', '联系电话', '收货地址', '订单备注']
+const courierFields = ['国内承运商', '国内运单号', '海外承运商', '海外运单号', '预计到达时间']
+const transactionColumns: DataTableColumn[] = [
+ { key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 155 },
+ { key: 'documentType', prop: 'documentType', label: '单据类型', minWidth: 120 },
+ { key: 'documentNo', prop: 'documentNo', label: '关联单号', minWidth: 160 },
+ { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
+ { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
+ { key: 'balanceQuantity', prop: 'balanceQuantity', label: '结存量', width: 95, align: 'right' },
+ { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
+]
+const transactions: InventoryTransaction[] = []
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-business/pages/PurchaseOrderEditView.vue b/src/modules/overseas-business/pages/PurchaseOrderEditView.vue
new file mode 100644
index 00000000..94d40445
--- /dev/null
+++ b/src/modules/overseas-business/pages/PurchaseOrderEditView.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-business/pages/PurchaseOrdersView.vue b/src/modules/overseas-business/pages/PurchaseOrdersView.vue
new file mode 100644
index 00000000..2f1f5065
--- /dev/null
+++ b/src/modules/overseas-business/pages/PurchaseOrdersView.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-business/pages/TransportOrderFormView.vue b/src/modules/overseas-business/pages/TransportOrderFormView.vue
new file mode 100644
index 00000000..a211bba3
--- /dev/null
+++ b/src/modules/overseas-business/pages/TransportOrderFormView.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-business/pages/TransportOrdersView.vue b/src/modules/overseas-business/pages/TransportOrdersView.vue
new file mode 100644
index 00000000..ac149dd3
--- /dev/null
+++ b/src/modules/overseas-business/pages/TransportOrdersView.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+ 新增订单
+ 编辑
+ 共 0 条{{ domainLabel }}订单
+
+
+
+
+
diff --git a/src/modules/overseas-business/permissions.ts b/src/modules/overseas-business/permissions.ts
new file mode 100644
index 00000000..1bcd4a16
--- /dev/null
+++ b/src/modules/overseas-business/permissions.ts
@@ -0,0 +1,10 @@
+export const overseasBusinessPermissions = {
+ purchaseList: 'platform:overseas-business:purchase:list',
+ purchaseEdit: 'platform:overseas-business:purchase:edit',
+ truckList: 'platform:overseas-business:truck:list',
+ truckCreate: 'platform:overseas-business:truck:create',
+ truckEdit: 'platform:overseas-business:truck:edit',
+ looseboardList: 'platform:overseas-business:looseboard:list',
+ looseboardCreate: 'platform:overseas-business:looseboard:create',
+ looseboardEdit: 'platform:overseas-business:looseboard:edit',
+} as const
diff --git a/src/modules/overseas-business/routes.test.ts b/src/modules/overseas-business/routes.test.ts
new file mode 100644
index 00000000..5e68c088
--- /dev/null
+++ b/src/modules/overseas-business/routes.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { overseasBusinessPermissions as permission } from './permissions'
+import { overseasBusinessRoutes } from './routes'
+
+describe('overseas business routes', () => {
+ it('keeps the frozen paths and permissions', () => {
+ expect(overseasBusinessRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
+ ['overseas-business/purchases', permission.purchaseList],
+ ['overseas-business/purchases/:purchaseId', permission.purchaseList],
+ ['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
+ ['overseas-business/trucks', permission.truckList],
+ ['overseas-business/trucks/new', permission.truckCreate],
+ ['overseas-business/trucks/:orderId/edit', permission.truckEdit],
+ ['overseas-business/looseboards', permission.looseboardList],
+ ['overseas-business/looseboards/new', permission.looseboardCreate],
+ ['overseas-business/looseboards/:orderId/edit', permission.looseboardEdit],
+ ])
+ })
+
+ it('does not invent excluded create or detail routes', () => {
+ const paths = overseasBusinessRoutes.map((route) => route.path)
+ expect(paths).not.toContain('overseas-business/purchases/new')
+ expect(paths).not.toContain('overseas-business/trucks/:orderId')
+ expect(paths).not.toContain('overseas-business/looseboards/:orderId')
+ expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
+ })
+})
diff --git a/src/modules/overseas-business/routes.ts b/src/modules/overseas-business/routes.ts
new file mode 100644
index 00000000..20dc5976
--- /dev/null
+++ b/src/modules/overseas-business/routes.ts
@@ -0,0 +1,65 @@
+import type { RouteRecordRaw } from 'vue-router'
+import { overseasBusinessPermissions as permission } from './permissions'
+
+export const overseasBusinessRoutes = [
+ {
+ path: 'overseas-business/purchases',
+ name: 'overseas-business-purchases',
+ component: () => import('./pages/PurchaseOrdersView.vue'),
+ meta: { title: '海外采购订单', description: '查询海外采购订单与库存变化', permission: permission.purchaseList },
+ },
+ {
+ path: 'overseas-business/purchases/:purchaseId',
+ name: 'overseas-business-purchase-detail',
+ component: () => import('./pages/PurchaseOrderDetailView.vue'),
+ meta: { title: '海外采购订单详情', description: '查看商品、库存、订单、快递与进销存明细', permission: permission.purchaseList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-business/purchases/:purchaseId/edit',
+ name: 'overseas-business-purchase-edit',
+ component: () => import('./pages/PurchaseOrderEditView.vue'),
+ meta: { title: '编辑海外采购订单', description: '维护采购商品、库存和快递信息', permission: permission.purchaseEdit, showPageHeading: false },
+ },
+ {
+ path: 'overseas-business/trucks',
+ name: 'overseas-business-trucks',
+ component: () => import('./pages/TransportOrdersView.vue'),
+ props: { domain: 'truck' },
+ meta: { title: '本土卡车', description: '管理海外本土卡车订单', permission: permission.truckList },
+ },
+ {
+ path: 'overseas-business/trucks/new',
+ name: 'overseas-business-truck-new',
+ component: () => import('./pages/TransportOrderFormView.vue'),
+ props: { domain: 'truck' },
+ meta: { title: '新增本土卡车订单', description: '创建海外本土卡车订单', permission: permission.truckCreate, showPageHeading: false },
+ },
+ {
+ path: 'overseas-business/trucks/:orderId/edit',
+ name: 'overseas-business-truck-edit',
+ component: () => import('./pages/TransportOrderFormView.vue'),
+ props: { domain: 'truck' },
+ meta: { title: '编辑本土卡车订单', description: '维护海外本土卡车订单', permission: permission.truckEdit, showPageHeading: false },
+ },
+ {
+ path: 'overseas-business/looseboards',
+ name: 'overseas-business-looseboards',
+ component: () => import('./pages/TransportOrdersView.vue'),
+ props: { domain: 'looseboard' },
+ meta: { title: '本土散板', description: '管理海外本土散板订单', permission: permission.looseboardList },
+ },
+ {
+ path: 'overseas-business/looseboards/new',
+ name: 'overseas-business-looseboard-new',
+ component: () => import('./pages/TransportOrderFormView.vue'),
+ props: { domain: 'looseboard' },
+ meta: { title: '新增本土散板订单', description: '创建海外本土散板订单', permission: permission.looseboardCreate, showPageHeading: false },
+ },
+ {
+ path: 'overseas-business/looseboards/:orderId/edit',
+ name: 'overseas-business-looseboard-edit',
+ component: () => import('./pages/TransportOrderFormView.vue'),
+ props: { domain: 'looseboard' },
+ meta: { title: '编辑本土散板订单', description: '维护海外本土散板订单', permission: permission.looseboardEdit, showPageHeading: false },
+ },
+] satisfies RouteRecordRaw[]
diff --git a/src/modules/overseas-business/types.ts b/src/modules/overseas-business/types.ts
new file mode 100644
index 00000000..9a7a296f
--- /dev/null
+++ b/src/modules/overseas-business/types.ts
@@ -0,0 +1,137 @@
+export interface OverseasBusinessPage {
+ records: T[]
+ page: number
+ size: number
+ total: number
+ pages: number
+}
+
+export interface OverseasPurchaseQuery {
+ page?: number
+ size?: number
+ orderNo?: string
+ category?: string
+ productName?: string
+}
+
+export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'CONFIRMED' | 'PURCHASING' | 'COMPLETED'
+
+export interface InventoryTransaction {
+ id: string
+ occurredAt: string
+ documentType: string
+ documentNo: string
+ inboundQuantity: number
+ outboundQuantity: number
+ balanceQuantity: number
+ remark: string | null
+}
+
+export interface OverseasPurchaseOrder {
+ id: string
+ orderNo: string
+ customerId: string
+ customerName: string
+ purchaseWarehouseId: string
+ purchaseWarehouseName: string
+ category: string
+ productName: string
+ specification: string | null
+ productUrl: string | null
+ purchaseQuantity: number
+ unitPrice: string | null
+ currency: string
+ inboundQuantity: number
+ outboundQuantity: number
+ stockQuantity: number
+ receivingAddress: string | null
+ domesticCarrier: string | null
+ domesticTrackingNo: string | null
+ overseasCarrier: string | null
+ overseasTrackingNo: string | null
+ estimatedArrivalAt: string | null
+ contactName: string | null
+ contactPhone: string | null
+ status: OverseasPurchaseStatus
+ remark: string | null
+ inventoryTransactions: InventoryTransaction[]
+ version: number
+}
+
+export interface UpdateOverseasPurchaseRequest {
+ category: string
+ productName: string
+ specification: string | null
+ productUrl: string | null
+ inboundQuantity: number
+ outboundQuantity: number
+ status: OverseasPurchaseStatus
+ domesticCarrier: string | null
+ domesticTrackingNo: string | null
+ overseasCarrier: string | null
+ overseasTrackingNo: string | null
+ estimatedArrivalAt: string | null
+ remark: string | null
+ expectedVersion: number
+}
+
+export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
+export type LocalTransportStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED'
+export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
+export type PalletRequired = 'YES' | 'NO'
+
+export interface LocalTransportOrder {
+ id: string
+ orderNo: string
+ transportType: LocalTransportType
+ origin: string
+ destination: string
+ originAddress: string | null
+ destinationAddress: string | null
+ contactName: string | null
+ contactPhone: string | null
+ containerType: string | null
+ pieces: number
+ grossWeightKg: string | null
+ volumeCbm: string | null
+ budgetAmount: string | null
+ currency: string
+ requiredDate: string | null
+ status: LocalTransportStatus
+ version: number
+}
+
+export interface LocalLooseboardOrder extends LocalTransportOrder {
+ transportMode: LooseboardTransportMode
+ palletRequired: PalletRequired
+}
+
+export interface LocalTransportQuery {
+ page?: number
+ size?: number
+ status?: LocalTransportStatus | ''
+ routeKeyword?: string
+}
+
+export interface SaveLocalTransportOrderRequest {
+ transportType: LocalTransportType | ''
+ origin: string
+ destination: string
+ originAddress: string | null
+ destinationAddress: string | null
+ contactName: string | null
+ contactPhone: string | null
+ containerType: string | null
+ pieces: number
+ grossWeightKg: string | null
+ volumeCbm: string | null
+ budgetAmount: string | null
+ currency: string
+ requiredDate: string | null
+ expectedVersion?: number
+}
+
+export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest {
+ transportMode: LooseboardTransportMode | ''
+ palletRequired: PalletRequired | ''
+}
diff --git a/src/modules/overseas-master-data/CONTRACT_GAPS.md b/src/modules/overseas-master-data/CONTRACT_GAPS.md
new file mode 100644
index 00000000..9ee8b3c3
--- /dev/null
+++ b/src/modules/overseas-master-data/CONTRACT_GAPS.md
@@ -0,0 +1,12 @@
+# 海外通用资料接口缺口
+
+当前仓库没有海外仓报价、汇率管理和车辆管理的已确认海外区域 API、请求/响应 DTO、错误码或写操作权限资源。
+
+因此本模块当前只冻结正式路由、页面结构、字段、承载方式和只读权限,不包含内存示例数据或模拟成功:
+
+- 海外仓报价:`platform:overseas-master-data:quote:list`
+- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
+- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
+
+新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限,但编码与接口需等海外区域契约确认后再接入。列表权限只包含对应列表、详情和只读辅助数据访问。
+
diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
new file mode 100644
index 00000000..5d22dd9b
--- /dev/null
+++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
@@ -0,0 +1,20 @@
+# 四期海外通用资料原型核对矩阵
+
+事实来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/10-general.html`、`prototype-src/overlays.html`、`prototype-src/app.js`。
+
+| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 |
+|---|---|---|---|---|---|---|
+| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类页签 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 不新增重复页面 | 建议类型码 `OVERSEAS_PARTNER` 未生效,不得冒充 `OVERSEAS` |
+| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 现有仓库页暂不开放该类型 | 后端正式接受合作仓类型后复用现有仓库弹框和写权限 |
+| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 正式模块列表页、结构化空态 | 保留原型字段;等待海外查询接口 |
+| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 写入口禁用,详情无记录时不生成示例对象 | 接口接入后使用居中弹框,不改抽屉或独立页 |
+| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
+| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
+| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 正式模块列表页、结构化空态 | 统一展示 `1 USD = X CNY`,等待查询接口 |
+| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 更新按钮禁用 | 等写接口与动作权限,不模拟覆盖成功 |
+| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
+| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 正式详情路由;无记录时显示契约待接入态 | 不改弹框或抽屉,不生成示例车辆 |
+| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 写按钮禁用 | 等写接口与动作权限,不模拟保存成功 |
+| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
+
+原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
diff --git a/src/modules/overseas-master-data/components/ContractPendingNotice.vue b/src/modules/overseas-master-data/components/ContractPendingNotice.vue
new file mode 100644
index 00000000..6ebba525
--- /dev/null
+++ b/src/modules/overseas-master-data/components/ContractPendingNotice.vue
@@ -0,0 +1,15 @@
+
+
+
+
+ {{ domain }}海外区域接口待接入
+ {{ detail ? '当前详情没有可查询的真实记录。' : '页面已按四期原型冻结字段与布局。' }}在海外 API、Token 与权限资源补齐前,不展示演示数据,也不会模拟查询或写操作成功。
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts
new file mode 100644
index 00000000..2f9deabc
--- /dev/null
+++ b/src/modules/overseas-master-data/index.ts
@@ -0,0 +1,3 @@
+export { overseasMasterDataRoutes } from './routes'
+export { overseasMasterDataPermissions } from './permissions'
+export type * from './types'
diff --git a/src/modules/overseas-master-data/pages/ExchangeRatesView.vue b/src/modules/overseas-master-data/pages/ExchangeRatesView.vue
new file mode 100644
index 00000000..5b0e5f7a
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/ExchangeRatesView.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+ {{ item.label }}{{ item.value }}
+
+
+
+
+
+ 美元兑人民币月平均汇率
统一口径:1 USD = X CNY
+ 更新月度汇率
+ —
+ —
+ 共 0 条月度汇率
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts
new file mode 100644
index 00000000..85cb207e
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts
@@ -0,0 +1,76 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+function source(name: string) {
+ return readFileSync(resolve(process.cwd(), `src/modules/overseas-master-data/pages/${name}.vue`), 'utf8')
+}
+
+describe('overseas master data pending-contract pages', () => {
+ it('gives every list query toolbar a unique stable preference key', () => {
+ const expectedKeys = new Map([
+ ['OverseasQuotesView', 'overseas-quotes-query'],
+ ['ExchangeRatesView', 'overseas-exchange-rates-query'],
+ ['VehiclesView', 'overseas-vehicles-query'],
+ ])
+ const actualKeys: string[] = []
+
+ for (const [name, expectedKey] of expectedKeys) {
+ const view = source(name)
+ const toolbar = view.match(/]*\/>/s)?.[0]
+ expect(toolbar, `${name} should render QueryToolbar`).toBeDefined()
+ expect(toolbar).toContain(`preference-key="${expectedKey}"`)
+ const actualKey = toolbar?.match(/preference-key="([^"]+)"/)?.[1]
+ expect(actualKey).toBe(expectedKey)
+ actualKeys.push(actualKey!)
+ }
+
+ expect(new Set(actualKeys).size).toBe(actualKeys.length)
+ })
+
+ it('keeps operation columns first and preserves the prototype fields', () => {
+ const quotes = source('OverseasQuotesView')
+ const exchangeRates = source('ExchangeRatesView')
+ const vehicles = source('VehiclesView')
+ expect(quotes.indexOf("key: 'actions'")).toBeLessThan(quotes.indexOf("key: 'name'"))
+ for (const label of ['报价名称', '海外仓', '服务商', '币种', '生效日期', '失效日期', '报价版本', '状态', '更新时间', '备注']) {
+ expect(quotes).toContain(`label: '${label}'`)
+ }
+ const exchangeColumns = exchangeRates.slice(exchangeRates.indexOf('const columns'))
+ expect(exchangeColumns.indexOf("key: 'actions'")).toBeLessThan(exchangeColumns.indexOf("key: 'month'"))
+ expect(vehicles.indexOf("key: 'actions'")).toBeLessThan(vehicles.indexOf("key: 'plateNumber'"))
+ for (const label of ['自有车辆', '外派车辆', '当前派送单', '最近更换原因']) expect(vehicles).toContain(label)
+ expect(vehicles).toContain("key: 'region'")
+ expect(vehicles).toContain("placeholder: '全部区域'")
+ expect(vehicles).toContain('class="status-tabs"')
+ expect(vehicles).toContain("'status-tab', { 'is-active': ownership === tab.value }")
+ expect(vehicles).not.toContain(' {
+ for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
+ const view = source(name)
+ expect(view).toContain('海外区域')
+ expect(view).toMatch(/]*disabled/)
+ expect(view).not.toContain('ElMessage.success')
+ }
+ })
+
+ it('uses an independent vehicle detail page without inventing a record', () => {
+ const detail = source('VehicleDetailView')
+ expect(detail).toContain('车辆信息')
+ expect(detail).toContain('派送订单明细')
+ expect(detail).toContain('派送更换记录')
+ expect(detail).toContain('')
+ expect(detail).not.toContain('AppDrawer')
+ expect(detail).not.toContain('const vehicle =')
+ })
+
+ it('records the prototype evidence before implementation', () => {
+ const matrix = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
+ for (const id of ['gen-partner-warehouse', 'gen-overseas-quote', 'gen-exchange-rate', 'vehicle-detail']) expect(matrix).toContain(id)
+ for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
+ expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
+ })
+})
diff --git a/src/modules/overseas-master-data/pages/OverseasQuotesView.vue b/src/modules/overseas-master-data/pages/OverseasQuotesView.vue
new file mode 100644
index 00000000..75a3a17c
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/OverseasQuotesView.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增报价
+ 导出Excel
+
+ —
+ 共 0 条海外仓报价
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/VehicleDetailView.vue b/src/modules/overseas-master-data/pages/VehicleDetailView.vue
new file mode 100644
index 00000000..abfaa1e8
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/VehicleDetailView.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
车辆详情
车辆 ID:{{ String(route.params.vehicleId || '—') }}
+
返回列表
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/pages/VehiclesView.vue b/src/modules/overseas-master-data/pages/VehiclesView.vue
new file mode 100644
index 00000000..6b6f75d2
--- /dev/null
+++ b/src/modules/overseas-master-data/pages/VehiclesView.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 添加车辆
+ 导出Excel
+
+ —
+ 共 0 条{{ regionLabel }}{{ ownership === 'OWNED' ? '自有' : '外派' }}车辆
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/permissions.ts b/src/modules/overseas-master-data/permissions.ts
new file mode 100644
index 00000000..14eebb96
--- /dev/null
+++ b/src/modules/overseas-master-data/permissions.ts
@@ -0,0 +1,6 @@
+export const overseasMasterDataPermissions = {
+ quoteList: 'platform:overseas-master-data:quote:list',
+ exchangeRateList: 'platform:overseas-master-data:exchange-rate:list',
+ vehicleList: 'platform:overseas-master-data:vehicle:list',
+} as const
+
diff --git a/src/modules/overseas-master-data/routes.test.ts b/src/modules/overseas-master-data/routes.test.ts
new file mode 100644
index 00000000..4774d0ec
--- /dev/null
+++ b/src/modules/overseas-master-data/routes.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { overseasMasterDataPermissions } from './permissions'
+import { overseasMasterDataRoutes } from './routes'
+
+describe('overseas master data routes', () => {
+ it('uses the frozen stable paths and list permissions', () => {
+ expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
+ ['overseas-master-data/quotes', overseasMasterDataPermissions.quoteList],
+ ['overseas-master-data/exchange-rates', overseasMasterDataPermissions.exchangeRateList],
+ ['overseas-master-data/vehicles', overseasMasterDataPermissions.vehicleList],
+ ['overseas-master-data/vehicles/:vehicleId', overseasMasterDataPermissions.vehicleList],
+ ])
+ })
+
+ it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
+ expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
+ expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
+ })
+
+ it('documents but does not activate the suggested partner warehouse type', () => {
+ const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/api/platformWarehouses.ts'), 'utf8')
+ const gap = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md'), 'utf8')
+ expect(gap).toContain('OVERSEAS_PARTNER')
+ expect(apiSource).not.toContain("'OVERSEAS_PARTNER'")
+ })
+})
diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts
new file mode 100644
index 00000000..abc8c418
--- /dev/null
+++ b/src/modules/overseas-master-data/routes.ts
@@ -0,0 +1,30 @@
+import type { RouteRecordRaw } from 'vue-router'
+import { overseasMasterDataPermissions as permission } from './permissions'
+
+export const overseasMasterDataRoutes = [
+ {
+ path: 'overseas-master-data/quotes',
+ name: 'overseas-master-data-quotes',
+ component: () => import('./pages/OverseasQuotesView.vue'),
+ meta: { title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList },
+ },
+ {
+ path: 'overseas-master-data/exchange-rates',
+ name: 'overseas-master-data-exchange-rates',
+ component: () => import('./pages/ExchangeRatesView.vue'),
+ meta: { title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList },
+ },
+ {
+ path: 'overseas-master-data/vehicles',
+ name: 'overseas-master-data-vehicles',
+ component: () => import('./pages/VehiclesView.vue'),
+ meta: { title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList },
+ },
+ {
+ path: 'overseas-master-data/vehicles/:vehicleId',
+ name: 'overseas-master-data-vehicle-detail',
+ component: () => import('./pages/VehicleDetailView.vue'),
+ meta: { title: '车辆详情', description: '查看车辆档案、派送订单与更换记录', permission: permission.vehicleList, showPageHeading: false },
+ },
+] satisfies RouteRecordRaw[]
+
diff --git a/src/modules/overseas-master-data/types.ts b/src/modules/overseas-master-data/types.ts
new file mode 100644
index 00000000..b7202c30
--- /dev/null
+++ b/src/modules/overseas-master-data/types.ts
@@ -0,0 +1,89 @@
+export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
+export type QuoteCurrency = 'USD' | 'CNY'
+export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
+export type VehicleOwnership = 'OWNED' | 'DISPATCHED'
+export type VehicleStatus = 'IN_SERVICE' | 'DISPATCHED' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
+
+export interface OverseasWarehouseFee {
+ item: string
+ billingUnit: string
+ unitPrice: number
+ minimumCharge: number | null
+ remark: string | null
+}
+
+export interface OverseasTransitPrice {
+ warehouseId: string
+ fbaWarehouseCode: string
+ deliveryMethod: string
+ estimatedMiles: number
+ price: number
+ minimumCharge: number | null
+ remark: string | null
+}
+
+export interface OverseasWarehouseQuote {
+ id: string
+ name: string
+ warehouseId: string
+ warehouseName: string
+ providerName: string
+ currency: QuoteCurrency
+ effectiveStart: string
+ effectiveEnd: string | null
+ version: string
+ status: MasterDataStatus
+ updatedAt: string
+ remark: string | null
+ warehouseFees: OverseasWarehouseFee[]
+ transitPrices: OverseasTransitPrice[]
+}
+
+export interface MonthlyExchangeRate {
+ month: string
+ usdToCnyAverage: number
+ updatedBy: string
+ updatedAt: string
+ remark: string | null
+}
+
+export interface VehicleSummary {
+ id: string
+ plateNumber: string
+ model: string
+ fuelConsumptionPer100Km: number
+ mileageKm: number
+ driverOrDispatchCompany: string | null
+ phone: string | null
+ region: VehicleRegion
+ ownership: VehicleOwnership
+ status: VehicleStatus
+ currentDeliveryOrderNo: string | null
+ latestReplacementReason: string | null
+}
+
+export interface VehicleDeliveryOrder {
+ orderNo: string
+ sourceOrderNo: string | null
+ customerName: string
+ destinationWarehouse: string
+ appointmentDate: string | null
+ status: string
+ relation: string
+}
+
+export interface VehicleReplacementHistory {
+ occurredAt: string
+ orderNo: string
+ action: string
+ relatedVehicle: string
+ reason: string
+ operatorName: string
+}
+
+export interface VehicleDetail extends VehicleSummary {
+ remark: string | null
+ deliveryOrders: VehicleDeliveryOrder[]
+ replacementHistory: VehicleReplacementHistory[]
+}
+
diff --git a/src/modules/overseas-warehouse/CONTRACT_GAPS.md b/src/modules/overseas-warehouse/CONTRACT_GAPS.md
new file mode 100644
index 00000000..1be95484
--- /dev/null
+++ b/src/modules/overseas-warehouse/CONTRACT_GAPS.md
@@ -0,0 +1,18 @@
+# 四期海外仓前端待补契约
+
+第一批正式页面只冻结路由、权限、字段与操作承载方式。以下后端/基础设施契约缺失前,页面不得用演示数据或本地成功提示替代真实调用:
+
+- 海外区域 API Base、海外 Access Token 的存储/刷新/选择机制,以及与现有国内 `http` 客户端的统一错误处理。
+- 尾程订单分页查询、详情、导入模板、批量导入、异步导出、批量转仓和入库计划接口;分页查询需明确“全部 / 待分配”页签的请求映射,以及出库单号、派送单号只读关联字段。
+- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
+- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
+- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
+- 托盘分页、详情、创建、编辑接口,以及海外仓/FBA 仓候选、编码唯一性与占用状态约束。
+- 箱号分页、详情、创建、编辑接口,以及产品/托盘/入库单关联校验;“已出库”只作为只读库存事实,本模块不承接出库动作。
+- 产品分页、详情、创建、编辑接口,以及 SKU 唯一性、品类和包装尺寸精度约定。
+- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
+- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
+
+范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
+
+当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导分页包络、URL、请求参数、时间/金额精度或写接口。待上述契约冻结后再建立实际 API 适配层,并逐页替换空态数据源。
diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md
new file mode 100644
index 00000000..0a0d33ba
--- /dev/null
+++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md
@@ -0,0 +1,57 @@
+# 四期海外仓原型核对矩阵
+
+原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`。
+
+| 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 |
+|---|---|---|---|---|---|
+| 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 |
+| 尾程订单 | 详情 | `page-last-mile-order-detail`,899–909 行;`openLastMileOrderDetail()`、`renderLastMileOrderDetail()` | 独立详情页,紧凑页头;订单信息、订单明细、计划信息、关联单据、货物信息、操作记录两列分区 | 正式参数路由与 `LastMileOrderDetailView.vue`;接口待接入时保留字段、空表与明确契约态 | 保持独立页面,不改为抽屉或弹框;原型中的出库计划和派送字段因本期边界排除,不形成操作入口 |
+| 尾程订单 | 导入 | `modal-lmo-import`,933 行 | 居中弹框,约 980px | 无正式实现 | 后端导入契约未定义,本批不提供可点击成功入口 |
+| 尾程订单 | 导出字段 | `modal-lmo-field-selector`,935 行 | 居中弹框,约 760px | 无正式实现 | 后端异步导出契约未定义,本批不模拟导出 |
+| 尾程订单 | 批量转仓 | `modal-lmo-batch-transfer`,921 行 | 居中弹框,约 720px | 无正式实现 | 属库内操作,但写接口/幂等契约未定义;暂不提供写入口 |
+| 尾程订单 | 入库计划 | `modal-lmo-inbound-plan`,912 行 | 居中弹框,约 720px | 无正式实现 | 属本期入仓链路;待写接口与状态机冻结后按原型实现 |
+| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
+| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 建立正式列表路由;保留筛选与列契约,接口未接时不填充演示数据 |
+| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue`;字段和费用空态已落位 | 保持独立页面,不改成抽屉;真实查询与费用数据等待海外区域接口 |
+| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
+| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
+| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
+| 库存管理 | 列表/筛选 | `page-inventory-overview`,51–89 行;`unifiedInventorySearch()`、`unifiedInventoryReset()` | 独立列表页,六项紧凑汇总、查询区、横向宽表 | 无正式页面 | 建立正式列表路由;汇总与列表在无接口时保持零/空且明确说明未接契约 |
+| 库存管理 | 详情 | `page-warehouse-stock-detail`,938–983 行;`renderWarehouseStockDetail()` 6624–6646 行 | 独立详情页;商品/库存汇总左右并列,库位库存、托盘箱号、进销存明细连续分区 | 正式参数路由与 `InventoryDetailView.vue`;字段和三类明细空态已落位 | 保持独立页面;进销存仅为只读库存事实,不提供出库或派送操作 |
+| 库存管理 | 导出 | 列表页“导出Excel”按钮 | 页面工具栏动作,具体浮层未定义 | 无正式实现 | 待 File 导出类型与海外 API 契约补齐,不模拟成功 |
+
+第一批明确不实现:`overseas-outbound`、`fba-outbound-order*`、`fba-return-outbound`、尾程订单的出库计划及任何海外派送/库存离仓操作。
+
+尾程列表按 `page-last-mile-orders` 保留“全部 / 待分配”状态页签,并保留入库单号、出库单号、派送单号、换标单号的原型列顺序;其中出库单号和派送单号仅为只读关联事实,不提供链接、详情跳转或操作入口。箱号列表遵循 `renderFbaCarton()`:仅“在库”记录显示编辑入口,“已出库”记录只读。
+
+第一批桌面布局按 `1920×1080` 验收:列表使用紧凑查询区与横向宽表;详情使用连续白底、双列或三列紧凑字段分区,长表保持横向滚动。当前缺少人工页面截图,视觉结果待总控按真实页面截图确认。
+
+## 第二批:换标单、托盘、箱号、产品库
+
+| 页面 | 操作 | 原型证据 | 原型承载方式与尺寸 | 实施结论 |
+|---|---|---|---|---|
+| 换标单 | 列表/筛选 | `page-fba-relabel-order`,531–555 行;`renderFbaRelabelOrder()` 8042–8058 行 | 独立列表页;SKU、状态筛选;换标单号、SKU、中文品名、箱数、预算总价、贴标要求、文件、状态 | 建立正式列表;操作列按仓库规则移到第一列;无查询接口时保持空表和契约态 |
+| 换标单 | 详情 | `page-fba-relabel-order-detail`,696–699 行;`openFbaRelabelOrderDetail()` 8605–8613 行 | 独立详情页;单据信息、商品信息、作业要求、费用明细 | 建立正式参数路由,详情复用 `list` 权限;不改为弹框或抽屉 |
+| 换标单 | 新增 | `modal-fba-relabel-form`,556–566 行;`openFbaRelabelCreate()` 7994–8001 行 | 居中弹框,最大宽度约 760px;订单信息与作业要求分组 | 按原型建立 `create` 权限入口与弹框;写接口未冻结,保存禁用且不模拟成功 |
+| 换标单 | 编辑/审核 | 同一 `modal-fba-relabel-form`;`openFbaRelabelEdit()` 8003–8022 行 | 居中弹框,约 760px;显示状态和预算报价,提交文案为“提交审核” | 使用独立 `edit` 权限;仅保留承载和字段契约,端点/状态机未冻结前不提交 |
+| 托盘管理 | 列表/筛选 | `page-fba-pallet`,626–652 行;`renderFbaPallet()` 8319–8335 行 | 独立列表页;编码、类型、仓库、状态筛选;横向表格 | 建立正式列表;操作列第一列;仓库选项等待海外基础资料接口 |
+| 托盘管理 | 详情 | `page-fba-pallet-detail`,702–705 行;`openFbaPalletDetail()` 8615–8621 行 | 独立详情页;基础信息、使用信息 | 建立正式参数路由,详情复用 `list` 权限 |
+| 托盘管理 | 新增/编辑 | `modal-fba-pallet-form`,654 行;`openFbaPalletForm()` 8358–8375 行 | 同一居中弹框,最大宽度约 760px;两列字段 | 分别使用 `create`、`edit` 权限;保存禁用,不发明 URL 或本地写入 |
+| 箱号管理 | 列表/筛选 | `page-fba-carton`,657–681 行;`renderFbaCarton()` 8428–8448 行 | 独立列表页;箱号、FBA 仓、状态筛选 | 建立正式列表;操作列第一列;“已出库”仅作为只读库存事实,不提供离仓动作 |
+| 箱号管理 | 详情 | `page-fba-carton-detail`,708–711 行;`openFbaCartonDetail()` 8623–8630 行 | 独立详情页;基础信息、商品信息 | 建立正式参数路由,详情复用 `list` 权限;关联出库单只读展示不链接出库流程 |
+| 箱号管理 | 新增/编辑 | `overlays.html` 的 `modal-fba-carton-form`,1528–1541 行;`openFbaCartonForm()` 8460–8481 行 | 同一居中弹框,最大宽度约 860px;两列字段;已出库不可编辑 | 分别使用 `create`、`edit` 权限;不实现出库状态变更,保存禁用 |
+| 产品库 | 列表/筛选 | `page-fba-product`,720–745 行;`renderFbaProduct()` 8695–8711 行 | 独立列表页;SKU、中文品名、品类筛选 | 建立正式列表;操作列第一列 |
+| 产品库 | 详情 | `page-fba-product-detail`,714–717 行;`openFbaProductDetail()` 8683–8690 行 | 独立详情页;基础信息、包装信息、其他信息 | 建立正式参数路由,详情复用 `list` 权限 |
+| 产品库 | 新增/编辑 | `modal-fbap-add`,748–771 行;`openFbapModal()`、`openFbapEditModal()` 8753–8767 行 | 同一居中弹框,最大宽度约 560px;单列标签/控件布局 | 分别使用 `create`、`edit` 权限;保存禁用,不在前端生成产品数据 |
+
+第二批所有新增/编辑弹框按原型尺寸和字段顺序承载;接口未冻结时只用于展示已确认的交互容器,不提供可成功提交路径。导出接口同样未定义,本批不显示可用导出动作。
+
+## 第三批:库存只读支撑页
+
+| 页面 | 操作 | 原型证据 | 原型承载方式与布局 | 实施结论 |
+|---|---|---|---|---|
+| 进销存记录 | 列表/筛选 | `page-inventory-transactions`,986–1007 行;`renderInventoryTransactions()` 6573–6582 行;`inventoryTransactionSearch()`、`inventoryTransactionReset()` 6584–6599 行 | 从库存管理进入的独立列表页;日期、客户/货主、海外仓、SKU、业务类型及业务/入库/出库单号筛选;约 2050px 横向表格 | 建立无独立菜单的正式路由,复用库存 `list` 权限;出库单号与出库数量仅作为只读库存流水事实,不提供出库详情跳转或离仓操作 |
+| 进销存记录 | 返回 | `backToInventoryManagement()` 6606–6609 行 | 返回库存管理列表 | 页面工具栏提供明确返回按钮 |
+| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
+
+`overseas-inbound`、`overseas-relabel` 等原型库存视图用于联查入仓/换标库存事实,不新增重复菜单页;当前正式入库单、换标单、库存列表与详情已经分别承载对应入口。`purchase-stock*` 和 `fba-return-stock` 是否作为独立正式页面尚无本轮导航与接口契约,不在本批自行扩展。
diff --git a/src/modules/overseas-warehouse/components/ContractPendingFormDialog.vue b/src/modules/overseas-warehouse/components/ContractPendingFormDialog.vue
new file mode 100644
index 00000000..19a8d1f9
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/ContractPendingFormDialog.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 弹框按原型保留字段与尺寸;端点、状态机和幂等契约冻结前不可提交,也不会在本地模拟保存成功。
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/components/ContractPendingNotice.vue b/src/modules/overseas-warehouse/components/ContractPendingNotice.vue
new file mode 100644
index 00000000..4da1c798
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/ContractPendingNotice.vue
@@ -0,0 +1,14 @@
+
+
+
+
+ {{ domain }}海外区域接口待接入
+ 页面已按四期原型冻结字段与布局;在海外 API、Token 与权限资源补齐前,不展示演示数据,也不会模拟查询或写操作成功。
+
+
+
+
diff --git a/src/modules/overseas-warehouse/components/OverseasDetailShell.vue b/src/modules/overseas-warehouse/components/OverseasDetailShell.vue
new file mode 100644
index 00000000..24939f98
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/OverseasDetailShell.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue b/src/modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue
new file mode 100644
index 00000000..9d4f9602
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ {{ group.title }}
+ - {{ field }}
- —
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/CartonDetailView.vue b/src/modules/overseas-warehouse/pages/CartonDetailView.vue
new file mode 100644
index 00000000..77dc9d41
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/CartonDetailView.vue
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/CartonsView.vue b/src/modules/overseas-warehouse/pages/CartonsView.vue
new file mode 100644
index 00000000..9a24a8f3
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/CartonsView.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+ 新增箱号
+ 查看编辑
+ 共 0 条箱号记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
new file mode 100644
index 00000000..12abcff9
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+ {{ group.title }}
+ - {{ label }}
- —
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
new file mode 100644
index 00000000..a4b87c3f
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue
new file mode 100644
index 00000000..f449a0e9
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+ 库存状态汇总
+ - {{ label }}
- —
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue b/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue
new file mode 100644
index 00000000..c08d9d94
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+ 出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/InventoryView.vue b/src/modules/overseas-warehouse/pages/InventoryView.vue
new file mode 100644
index 00000000..c04a1083
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InventoryView.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+ {{ metric[0] }}{{ metric[1] }}
+
+
+ 进销存记录
+ 查看
+ 共 0 条库存记录
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
new file mode 100644
index 00000000..10d18324
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
new file mode 100644
index 00000000..4255edca
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/PalletDetailView.vue b/src/modules/overseas-warehouse/pages/PalletDetailView.vue
new file mode 100644
index 00000000..4505d371
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/PalletDetailView.vue
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/PalletsView.vue b/src/modules/overseas-warehouse/pages/PalletsView.vue
new file mode 100644
index 00000000..df742a5e
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/PalletsView.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+ 新增托盘
+ 查看编辑
+ 共 0 条托盘
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/ProductDetailView.vue b/src/modules/overseas-warehouse/pages/ProductDetailView.vue
new file mode 100644
index 00000000..c1e913df
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/ProductDetailView.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/ProductsView.vue b/src/modules/overseas-warehouse/pages/ProductsView.vue
new file mode 100644
index 00000000..c27b6d18
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/ProductsView.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+ 新增产品
+ 查看编辑
+ 共 0 条产品
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
new file mode 100644
index 00000000..59457cab
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue
new file mode 100644
index 00000000..22a45fa7
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+ 新增换标单
+ 查看审核
+ 共 0 条换标单
+
+
+
+ 订单信息
+
+
+
+
+
+
+
+ 作业要求
+
+ 选择文件
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
new file mode 100644
index 00000000..bf9bf894
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
@@ -0,0 +1,209 @@
+import { readFileSync, readdirSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+function source(fileName: string) {
+ return readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages', fileName), 'utf8')
+}
+
+function columnKeys(fileName: string) {
+ const value = source(fileName)
+ const start = value.indexOf('const columns: DataTableColumn[]')
+ const end = value.indexOf('\n]', start)
+ return [...value.slice(start, end).matchAll(/\{ key: '([^']+)'/g)].map((match) => match[1])
+}
+
+describe('overseas warehouse first-batch prototype contract', () => {
+ it('gives every formal list query toolbar a unique stable preference key', () => {
+ const expected = new Map([
+ ['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
+ ['InboundOrdersView.vue', 'overseas-inbound-orders-query'],
+ ['InventoryView.vue', 'overseas-inventory-query'],
+ ['InventoryTransactionsView.vue', 'overseas-inventory-transactions-query'],
+ ['RelabelOrdersView.vue', 'overseas-relabel-orders-query'],
+ ['PalletsView.vue', 'overseas-pallets-query'],
+ ['CartonsView.vue', 'overseas-cartons-query'],
+ ['ProductsView.vue', 'overseas-products-query'],
+ ])
+ const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
+ const value = source(fileName)
+ const toolbar = value.match(/]*\/>/)?.[0]
+ expect(toolbar, `${fileName} should render a QueryToolbar`).toBeDefined()
+ expect(toolbar).toContain(`preference-key="${preferenceKey}"`)
+ return preferenceKey
+ })
+ const queryToolbarPages = readdirSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages'))
+ .filter((fileName) => fileName.endsWith('.vue') && source(fileName).includes(' {
+ for (const fileName of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'InventoryView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
+ const value = source(fileName)
+ const columns = value.slice(value.indexOf('const columns: DataTableColumn[]'))
+ expect(columns.match(/\{\s*key: '([^']+)'/)?.[1]).toBe('actions')
+ }
+ })
+
+ it('preserves the prototype dialog carriers and desktop widths', () => {
+ const expected = [
+ ['RelabelOrdersView.vue', '760px', '新增换标单', '审核换标单'],
+ ['PalletsView.vue', '760px', '新增托盘', '编辑托盘'],
+ ['CartonsView.vue', '860px', '新增箱号', '编辑箱号'],
+ ['ProductsView.vue', '560px', '新增产品', '编辑产品'],
+ ]
+ for (const [fileName, width, createTitle, editTitle] of expected) {
+ const value = source(fileName)
+ expect(value).toContain(' {
+ for (const fileName of ['RelabelOrderDetailView.vue', 'PalletDetailView.vue', 'CartonDetailView.vue', 'ProductDetailView.vue']) {
+ const value = source(fileName)
+ expect(value).toContain(' {
+ const dialog = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/components/ContractPendingFormDialog.vue'), 'utf8')
+ expect(dialog).toContain('confirm-disabled')
+ expect(dialog).toContain('不可提交')
+ expect(dialog).not.toContain('@confirm=')
+ for (const fileName of ['RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
+ const value = source(fileName)
+ expect(value).not.toContain('ElMessage.success')
+ expect(value).not.toMatch(/https?:\/\//)
+ }
+ })
+
+ it('implements three prototype-defined details as formal pages', () => {
+ for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
+ const value = source(fileName)
+ expect(value).toContain(' {
+ const value = source('LastMileOrderDetailView.vue')
+ expect(value).not.toContain('计划出库日期')
+ expect(value).not.toContain('派送方式')
+ expect(value).not.toContain('保存并生成出库单')
+ expect(value).not.toContain('海外派送')
+ })
+
+ it('shows contract-pending empty states instead of demo data or fake success actions', () => {
+ for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
+ const value = source(fileName)
+ expect(value).toContain(':data="[]"')
+ expect(value).toContain('契约待接入')
+ expect(value).not.toContain('ElMessage.success')
+ }
+ })
+
+ it('adds the prototype inventory transaction page as a read-only inventory route', () => {
+ const inventory = source('InventoryView.vue')
+ const transactions = source('InventoryTransactionsView.vue')
+ expect(inventory).toContain("name: 'overseas-inventory-transactions'")
+ expect(transactions).toContain('进销存记录筛选')
+ expect(transactions).toContain('业务订单号')
+ expect(transactions).toContain('入库单号')
+ expect(transactions).toContain('出库单号')
+ expect(transactions).toContain('出库相关字段仅展示既有库存流水事实')
+ expect(transactions).toContain("router.push({ name: 'overseas-inventory' })")
+ expect(transactions).not.toContain('保存')
+ expect(transactions).not.toContain('ElMessage.success')
+ expect(transactions).not.toMatch(/router\.push\([^)]*outbound/)
+ })
+
+ it('uses module presentation models instead of untyped records', () => {
+ for (const fileName of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'InventoryView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue', 'InventoryTransactionsView.vue']) {
+ const value = source(fileName)
+ expect(value).toContain("from '../types'")
+ expect(value).not.toContain('ref[]>')
+ }
+ })
+
+ it('keeps every formal list connected to its read-only detail route', () => {
+ const expected = [
+ ['LastMileOrdersView.vue', 'overseas-last-mile-order-detail'],
+ ['InboundOrdersView.vue', 'overseas-inbound-order-detail'],
+ ['InventoryView.vue', 'overseas-inventory-detail'],
+ ['RelabelOrdersView.vue', 'overseas-relabel-order-detail'],
+ ['PalletsView.vue', 'overseas-pallet-detail'],
+ ['CartonsView.vue', 'overseas-carton-detail'],
+ ['ProductsView.vue', 'overseas-product-detail'],
+ ]
+ for (const [fileName, routeName] of expected) {
+ const value = source(fileName)
+ expect(value).toContain(routeName)
+ expect(value).toContain('查看')
+ }
+ })
+
+ it('preserves last-mile status tabs and read-only outbound association columns without links', () => {
+ const value = source('LastMileOrdersView.vue')
+ expect(value).toContain('aria-label="尾程订单状态"')
+ expect(value).toContain('全部')
+ expect(value).toContain('待分配')
+ expect(value.indexOf("label: '入库单号'")).toBeLessThan(value.indexOf("label: '出库单号'"))
+ expect(value.indexOf("label: '出库单号'")).toBeLessThan(value.indexOf("label: '派送单号'"))
+ expect(value.indexOf("label: '派送单号'")).toBeLessThan(value.indexOf("label: '换标单号'"))
+ expect(value).not.toMatch(/router\.push\([^)]*(outbound|dispatch)/)
+ })
+
+ it('keeps outbound cartons read-only and uses the prototype product confirmation copy', () => {
+ const cartons = source('CartonsView.vue')
+ const products = source('ProductsView.vue')
+ expect(cartons).toContain("function canEditCarton(source: object)")
+ expect(cartons).toContain("=== '在库'")
+ expect(cartons).toContain('access.can(permission.cartonEdit) && canEditCarton(row)')
+ expect(products).toContain('confirm-text="确定"')
+ })
+
+ it('keeps every prototype list column in its frozen order', () => {
+ expect(columnKeys('LastMileOrdersView.vue')).toEqual([
+ 'actions', 'orderNo', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
+ 'transferWarehouse', 'fbaWarehouse', 'containerNo', 'containerStatus', 'abnormal', 'held', 'pallets',
+ 'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
+ 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'consolidated', 'status',
+ 'internalRemark', 'customerRemark', 'warehouseRemark',
+ ])
+ expect(columnKeys('InboundOrdersView.vue')).toEqual([
+ 'actions', 'orderNo', 'inboundCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'volume',
+ 'pallets', 'status', 'appointmentStatus', 'dispatchStatus',
+ ])
+ expect(columnKeys('InventoryView.vue')).toEqual([
+ 'actions', 'owner', 'warehouse', 'sku', 'productName', 'onHand', 'available', 'held', 'looseboard',
+ 'inTransit', 'purchase', 'status', 'updatedAt',
+ ])
+ expect(columnKeys('RelabelOrdersView.vue')).toEqual([
+ 'actions', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'labelRequirement', 'fileName', 'status',
+ ])
+ expect(columnKeys('PalletsView.vue')).toEqual([
+ 'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark',
+ ])
+ expect(columnKeys('CartonsView.vue')).toEqual([
+ 'actions', 'cartonNo', 'fbaWarehouse', 'sku', 'productName', 'quantity', 'weight', 'status',
+ ])
+ expect(columnKeys('ProductsView.vue')).toEqual([
+ 'actions', 'sku', 'productName', 'englishName', 'category', 'length', 'width', 'height', 'weight',
+ ])
+ expect(columnKeys('InventoryTransactionsView.vue')).toEqual([
+ 'occurredAt', 'owner', 'warehouse', 'sku', 'productName', 'businessType', 'businessOrderNo',
+ 'inboundOrderNo', 'outboundOrderNo', 'inboundQuantity', 'outboundQuantity', 'balance', 'operator', 'remark',
+ ])
+ })
+})
diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts
new file mode 100644
index 00000000..d2c821b4
--- /dev/null
+++ b/src/modules/overseas-warehouse/permissions.ts
@@ -0,0 +1,17 @@
+export const overseasWarehousePermissions = {
+ lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
+ inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
+ inventoryList: 'platform:overseas-warehouse:inventory:list',
+ relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
+ relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
+ relabelOrderEdit: 'platform:overseas-warehouse:relabel-order:edit',
+ palletList: 'platform:overseas-warehouse:pallet:list',
+ palletCreate: 'platform:overseas-warehouse:pallet:create',
+ palletEdit: 'platform:overseas-warehouse:pallet:edit',
+ cartonList: 'platform:overseas-warehouse:carton:list',
+ cartonCreate: 'platform:overseas-warehouse:carton:create',
+ cartonEdit: 'platform:overseas-warehouse:carton:edit',
+ productList: 'platform:overseas-warehouse:product:list',
+ productCreate: 'platform:overseas-warehouse:product:create',
+ productEdit: 'platform:overseas-warehouse:product:edit',
+} as const
diff --git a/src/modules/overseas-warehouse/routes.test.ts b/src/modules/overseas-warehouse/routes.test.ts
new file mode 100644
index 00000000..f1e6c41e
--- /dev/null
+++ b/src/modules/overseas-warehouse/routes.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import { overseasWarehouseRoutes } from './routes'
+import { overseasWarehousePermissions as permission } from './permissions'
+
+describe('overseas warehouse routes', () => {
+ it('uses list permission for each list and read-only detail route', () => {
+ expect(overseasWarehouseRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
+ ['overseas-warehouse/last-mile-orders', permission.lastMileOrderList],
+ ['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList],
+ ['overseas-warehouse/inbound-orders', permission.inboundOrderList],
+ ['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList],
+ ['overseas-warehouse/inventory', permission.inventoryList],
+ ['overseas-warehouse/inventory-transactions', permission.inventoryList],
+ ['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
+ ['overseas-warehouse/relabel-orders', permission.relabelOrderList],
+ ['overseas-warehouse/relabel-orders/:orderId', permission.relabelOrderList],
+ ['overseas-warehouse/pallets', permission.palletList],
+ ['overseas-warehouse/pallets/:palletId', permission.palletList],
+ ['overseas-warehouse/cartons', permission.cartonList],
+ ['overseas-warehouse/cartons/:cartonId', permission.cartonList],
+ ['overseas-warehouse/products', permission.productList],
+ ['overseas-warehouse/products/:productId', permission.productList],
+ ])
+ })
+
+ it('keeps all details as formal parameterized pages', () => {
+ const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
+ expect(details).toHaveLength(7)
+ expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
+ })
+
+ it('keeps create and edit permissions independent for second-batch resources', () => {
+ expect(new Set([
+ permission.relabelOrderCreate, permission.relabelOrderEdit,
+ permission.palletCreate, permission.palletEdit,
+ permission.cartonCreate, permission.cartonEdit,
+ permission.productCreate, permission.productEdit,
+ ]).size).toBe(8)
+ })
+})
diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts
new file mode 100644
index 00000000..09513e6e
--- /dev/null
+++ b/src/modules/overseas-warehouse/routes.ts
@@ -0,0 +1,95 @@
+import type { RouteRecordRaw } from 'vue-router'
+import { overseasWarehousePermissions as permission } from './permissions'
+
+export const overseasWarehouseRoutes = [
+ {
+ path: 'overseas-warehouse/last-mile-orders',
+ name: 'overseas-last-mile-orders',
+ component: () => import('./pages/LastMileOrdersView.vue'),
+ meta: { title: '尾程订单', description: '管理海外接柜后的尾程订单与入仓衔接', permission: permission.lastMileOrderList },
+ },
+ {
+ path: 'overseas-warehouse/last-mile-orders/:orderId',
+ name: 'overseas-last-mile-order-detail',
+ component: () => import('./pages/LastMileOrderDetailView.vue'),
+ meta: { title: '尾程订单详情', description: '查看尾程订单、计划、货物和操作记录', permission: permission.lastMileOrderList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-warehouse/inbound-orders',
+ name: 'overseas-inbound-orders',
+ component: () => import('./pages/InboundOrdersView.vue'),
+ meta: { title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
+ },
+ {
+ path: 'overseas-warehouse/inbound-orders/:orderId',
+ name: 'overseas-inbound-order-detail',
+ component: () => import('./pages/InboundOrderDetailView.vue'),
+ meta: { title: '入库单详情', description: '查看海外仓入库单与应收费用', permission: permission.inboundOrderList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-warehouse/inventory',
+ name: 'overseas-inventory',
+ component: () => import('./pages/InventoryView.vue'),
+ meta: { title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList },
+ },
+ {
+ path: 'overseas-warehouse/inventory-transactions',
+ name: 'overseas-inventory-transactions',
+ component: () => import('./pages/InventoryTransactionsView.vue'),
+ meta: { title: '进销存记录', description: '查看海外仓库存的只读变动流水', permission: permission.inventoryList },
+ },
+ {
+ path: 'overseas-warehouse/inventory/:inventoryId',
+ name: 'overseas-inventory-detail',
+ component: () => import('./pages/InventoryDetailView.vue'),
+ meta: { title: '库存详情', description: '查看商品、库位、托盘箱号和进销存明细', permission: permission.inventoryList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-warehouse/relabel-orders',
+ name: 'overseas-relabel-orders',
+ component: () => import('./pages/RelabelOrdersView.vue'),
+ meta: { title: '换标单', description: '管理海外仓换标作业要求与费用信息', permission: permission.relabelOrderList },
+ },
+ {
+ path: 'overseas-warehouse/relabel-orders/:orderId',
+ name: 'overseas-relabel-order-detail',
+ component: () => import('./pages/RelabelOrderDetailView.vue'),
+ meta: { title: '换标单详情', description: '查看换标单、作业要求与费用明细', permission: permission.relabelOrderList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-warehouse/pallets',
+ name: 'overseas-pallets',
+ component: () => import('./pages/PalletsView.vue'),
+ meta: { title: '托盘管理', description: '管理海外仓托盘主数据与使用状态', permission: permission.palletList },
+ },
+ {
+ path: 'overseas-warehouse/pallets/:palletId',
+ name: 'overseas-pallet-detail',
+ component: () => import('./pages/PalletDetailView.vue'),
+ meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-warehouse/cartons',
+ name: 'overseas-cartons',
+ component: () => import('./pages/CartonsView.vue'),
+ meta: { title: '箱号管理', description: '管理海外仓箱号、商品与托盘关联', permission: permission.cartonList },
+ },
+ {
+ path: 'overseas-warehouse/cartons/:cartonId',
+ name: 'overseas-carton-detail',
+ component: () => import('./pages/CartonDetailView.vue'),
+ meta: { title: '箱号详情', description: '查看箱号基础信息与商品信息', permission: permission.cartonList, showPageHeading: false },
+ },
+ {
+ path: 'overseas-warehouse/products',
+ name: 'overseas-products',
+ component: () => import('./pages/ProductsView.vue'),
+ meta: { title: '产品库', description: '管理海外仓商品与包装信息', permission: permission.productList },
+ },
+ {
+ path: 'overseas-warehouse/products/:productId',
+ name: 'overseas-product-detail',
+ component: () => import('./pages/ProductDetailView.vue'),
+ meta: { title: '产品详情', description: '查看商品基础信息与包装信息', permission: permission.productList, showPageHeading: false },
+ },
+] satisfies RouteRecordRaw[]
diff --git a/src/modules/overseas-warehouse/types.test.ts b/src/modules/overseas-warehouse/types.test.ts
new file mode 100644
index 00000000..2b03db67
--- /dev/null
+++ b/src/modules/overseas-warehouse/types.test.ts
@@ -0,0 +1,25 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/types.ts'), 'utf8')
+
+describe('overseas warehouse presentation contracts', () => {
+ it('covers every formal list without freezing an API transport', () => {
+ for (const name of [
+ 'LastMileOrderListItem', 'InboundOrderListItem', 'InventoryListItem',
+ 'RelabelOrderListItem', 'PalletListItem', 'CartonListItem',
+ 'ProductListItem', 'InventoryTransactionListItem',
+ ]) expect(source).toContain(`interface ${name}`)
+ expect(source).not.toContain('axios')
+ expect(source).not.toContain("from '@/shared/api/http'")
+ expect(source).not.toMatch(/https?:\/\//)
+ expect(source).not.toContain('tenantId')
+ })
+
+ it('marks outbound references as read-only inventory facts', () => {
+ expect(source.match(/Read-only inventory fact/g)).toHaveLength(2)
+ expect(source).not.toContain('createOutbound')
+ expect(source).not.toContain('updateOutbound')
+ })
+})
diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts
new file mode 100644
index 00000000..5b422a9e
--- /dev/null
+++ b/src/modules/overseas-warehouse/types.ts
@@ -0,0 +1,141 @@
+/**
+ * Prototype-derived presentation models for the Admin pages in this module.
+ * These types intentionally do not freeze overseas API URLs, pagination envelopes,
+ * identifiers, timestamps, money precision, or mutation request contracts.
+ */
+export interface LastMileOrderListItem {
+ orderNo: string
+ warning: string
+ orderType: string
+ rejectedAt: string
+ inboundCode: string
+ owner: string
+ warehouse: string
+ transferWarehouse: string
+ fbaWarehouse: string
+ containerNo: string
+ containerStatus: string
+ abnormal: string
+ held: string
+ pallets: number
+ cartons: number
+ volume: number
+ quantity: number
+ volumeWeight: number
+ portEta: string
+ portAta: string
+ warehouseEta: string
+ inboundOrderNo: string
+ /** Read-only association fact; this module does not implement the outbound workflow. */
+ outboundOrderNo: string
+ /** Read-only association fact; this module does not implement overseas delivery. */
+ dispatchOrderNo: string
+ relabelOrderNo: string
+ relabelStatus: string
+ consolidated: string
+ status: string
+ internalRemark: string
+ customerRemark: string
+ warehouseRemark: string
+}
+
+export interface InboundOrderListItem {
+ orderNo: string
+ inboundCode: string
+ type: string
+ warehouse: string
+ fbaWarehouse: string
+ owner: string
+ cartons: number
+ volume: number
+ pallets: number
+ status: string
+ appointmentStatus: string
+ dispatchStatus: string
+}
+
+export interface InventoryListItem {
+ id: string
+ owner: string
+ warehouse: string
+ sku: string
+ productName: string
+ onHand: number
+ available: number
+ held: number
+ looseboard: number
+ inTransit: number
+ purchase: number
+ status: string
+ updatedAt: string
+}
+
+export interface RelabelOrderListItem {
+ orderNo: string
+ relatedOrderNo: string
+ sku: string
+ productName: string
+ cartons: number
+ budgetTotal: number
+ labelRequirement: string
+ relabelCost?: number
+ fileName: string
+ status: string
+ remark: string
+}
+
+export interface PalletListItem {
+ code: string
+ type: string
+ warehouse: string
+ volume: number
+ purchasePrice: number
+ fbaWarehouse: string
+ status: string
+ remark: string
+}
+
+export interface CartonListItem {
+ cartonNo: string
+ fbaWarehouse: string
+ sku: string
+ productName: string
+ quantity: number
+ weight: number
+ status: string
+ palletNo: string
+ inboundOrderNo: string
+ /** Read-only inventory fact; this module does not implement the outbound workflow. */
+ outboundOrderNo: string
+ remark: string
+}
+
+export interface ProductListItem {
+ sku: string
+ productName: string
+ englishName: string
+ category: string
+ length: number
+ width: number
+ height: number
+ weight: number
+}
+
+export interface InventoryTransactionListItem {
+ id: string
+ occurredAt: string
+ owner: string
+ warehouse: string
+ sku: string
+ productName: string
+ businessType: string
+ businessOrderNo: string
+ inboundOrderNo: string
+ /** Read-only inventory fact; it is not an outbound-workflow link. */
+ outboundOrderNo: string
+ inboundQuantity: number
+ outboundQuantity: number
+ balance: number
+ operator: string
+ remark: string
+}
diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts
new file mode 100644
index 00000000..62188d33
--- /dev/null
+++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
+
+vi.mock('@/shared/api/http', async () => {
+ const actual = await vi.importActual('@/shared/api/http')
+ return { ...actual, http: { get: vi.fn() } }
+})
+
+const query = {
+ id: 9007199254740993n,
+ queryNo: 'CSQ-1',
+ customerId: 101,
+ oceanBookingId: 202,
+ containerBookingId: 303,
+ carrierCode: 'COSCO',
+ trackingType: 'CONTAINER_NO',
+ trackingNo: 'CSNU1234567',
+ status: 'MANUAL_REQUIRED',
+ attemptCount: '2',
+ resultPortScheduleId: 404,
+ version: '3',
+}
+
+describe('platformCarrierScheduleQueries', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('filters by real booking/container relations and preserves long ids as strings', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true, maxAttempts: '3', nextAllowedAt: null }], page: '1', size: '200', total: '1', pages: '1' } })
+ const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
+ expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
+ expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', attemptCount: 2, version: 3 }, expired: true, maxAttempts: 3 })
+ })
+
+ it('normalizes attempts and immutable history without creating query operations', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query, expired: false, maxAttempts: 3 }, attempts: [{ id: 1, queryId: query.id, windowAttemptNo: '2', carrierCode: 'COSCO', attemptStatus: 'FAILED', coverageGap: true }], histories: [{ id: 2, queryId: query.id, action: 'ATTEMPT', fromVersion: '1', toVersion: '2' }] } })
+ const result = await getCarrierScheduleQuery('9007199254740993')
+ expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993')
+ expect(result.attempts[0]).toMatchObject({ id: '1', queryId: '9007199254740993', windowAttemptNo: 2, attemptStatus: 'FAILED' })
+ expect(result.histories[0]).toMatchObject({ id: '2', fromVersion: 1, toVersion: 2 })
+ })
+})
diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts
new file mode 100644
index 00000000..0c18a8a2
--- /dev/null
+++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts
@@ -0,0 +1,52 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import type {
+ CarrierScheduleQueryAttempt,
+ CarrierScheduleQueryDetail,
+ CarrierScheduleQueryFilter,
+ CarrierScheduleQueryHistory,
+ CarrierScheduleQueryPage,
+ CarrierScheduleQueryResource,
+ CarrierScheduleQueryView,
+} from '../types'
+
+const basePath = '/platform/v1/carrier-schedule-queries'
+
+function optionalId(value: unknown): string | null { return value == null ? null : String(value) }
+function numberValue(value: unknown, fallback = 0): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback }
+
+function resource(raw: CarrierScheduleQueryResource): CarrierScheduleQueryResource {
+ return {
+ ...raw,
+ id: String(raw.id),
+ customerId: String(raw.customerId),
+ oceanBookingId: String(raw.oceanBookingId),
+ containerBookingId: optionalId(raw.containerBookingId),
+ resultPortScheduleId: optionalId(raw.resultPortScheduleId),
+ attemptCount: numberValue(raw.attemptCount),
+ version: numberValue(raw.version),
+ }
+}
+
+function view(raw: CarrierScheduleQueryView): CarrierScheduleQueryView {
+ return { ...raw, query: resource(raw.query), maxAttempts: numberValue(raw.maxAttempts), nextAllowedAt: raw.nextAllowedAt ?? raw.query.nextAllowedAt ?? null }
+}
+
+function attempt(raw: CarrierScheduleQueryAttempt): CarrierScheduleQueryAttempt {
+ return { ...raw, id: String(raw.id), queryId: String(raw.queryId), windowAttemptNo: numberValue(raw.windowAttemptNo), coverageGap: Boolean(raw.coverageGap) }
+}
+
+function history(raw: CarrierScheduleQueryHistory): CarrierScheduleQueryHistory {
+ return { ...raw, id: String(raw.id), queryId: String(raw.queryId), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion) }
+}
+
+export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFilter): Promise {
+ const response = await http.get, ApiEnvelope>(basePath, { params: filter })
+ const result = unwrapApiResponse(response)
+ return { ...result, records: (result.records || []).map(view), page: numberValue(result.page, filter.page), size: numberValue(result.size, filter.size), total: numberValue(result.total), pages: numberValue(result.pages) }
+}
+
+export async function getCarrierScheduleQuery(queryId: string): Promise {
+ const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(queryId)}`)
+ const result = unwrapApiResponse(response)
+ return { ...result, query: view(result.query), attempts: (result.attempts || []).map(attempt), histories: (result.histories || []).map(history) }
+}
diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts
new file mode 100644
index 00000000..044abc2e
--- /dev/null
+++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue'), 'utf8')
+
+describe('CarrierScheduleQueryPanel', () => {
+ it('uses the existing relation filters and never creates or executes polling tasks', () => {
+ expect(source).toContain('listCarrierScheduleQueries({ page: 1, size: 200')
+ expect(source).toContain('oceanBookingId: props.oceanBookingId')
+ expect(source).toContain('containerBookingId: props.containerBookingId')
+ expect(source).not.toContain('createCarrierSchedule')
+ expect(source).not.toContain('attemptCarrierSchedule')
+ })
+
+ it('shows source, observation/update times, history, failures and expiry truthfully', () => {
+ expect(source).toContain('最新来源')
+ expect(source).toContain('观测时间')
+ expect(source).toContain('来源更新时间')
+ expect(source).toContain('任务更新时间')
+ expect(source).toContain('查询尝试')
+ expect(source).toContain('变更历史')
+ expect(source).toContain('query.lastErrorMessage')
+ expect(source).toContain("return '结果已过期'")
+ })
+
+ it('keeps the missing node-event contract explicit instead of fabricating data', () => {
+ expect(source).toContain('待接口提供独立节点事实')
+ expect(source).toContain('不会模拟节点或自动创建任务')
+ })
+})
diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue
new file mode 100644
index 00000000..6991d525
--- /dev/null
+++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ - 查询任务
- {{ query.queryNo || query.id }}
- 跟踪标识
- {{ query.trackingNo || '—' }}
+ - 最新来源
- {{ latestSource }}
- 来源参考
- {{ sourceReference }}
+ - 最新节点
- {{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}
- 观测时间
- {{ formatTime(latestObservation) }}
+ - 来源更新时间
- {{ formatTime(query.sourceUpdatedAt) }}
- 任务更新时间
- {{ formatTime(query.updatedAt) }}
+ - 最近成功
- {{ formatTime(query.lastSuccessAt) }}
- 最近尝试
- {{ formatTime(query.lastAttemptAt) }}
+ - 结果过期时间
- {{ formatTime(query.expiresAt) }}
- 下次允许查询
- {{ formatTime(queryView?.nextAllowedAt) }}
+
+ {{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}
+
+
查询尝试
暂无查询尝试
- {{ formatTime(item.createdAt) }}{{ attemptText(item.attemptStatus) }}{{ item.errorMessage || item.manualReason || item.operatorName || item.carrierCode }}
+
变更历史
暂无变更历史
- {{ formatTime(item.createdAt) }}{{ historyText(item.action) }}{{ item.reason || item.operatorName || '系统记录' }}
+
+
+
+
+
+
diff --git a/src/modules/port-schedules/index.ts b/src/modules/port-schedules/index.ts
index ab38aabf..08215876 100644
--- a/src/modules/port-schedules/index.ts
+++ b/src/modules/port-schedules/index.ts
@@ -1,2 +1,3 @@
export { portScheduleRoutes } from './routes'
+export { default as CarrierScheduleQueryPanel } from './components/CarrierScheduleQueryPanel.vue'
export * from './types'
diff --git a/src/modules/port-schedules/pages/PortSchedulesView.test.ts b/src/modules/port-schedules/pages/PortSchedulesView.test.ts
index 2cef570b..bff7173a 100644
--- a/src/modules/port-schedules/pages/PortSchedulesView.test.ts
+++ b/src/modules/port-schedules/pages/PortSchedulesView.test.ts
@@ -84,4 +84,9 @@ describe('PortSchedulesView', () => {
expect(source).toContain(' {
+ expect(source).toContain('CarrierScheduleQueryPanel')
+ expect(source).toContain('v-if="dialogReadonly && form.oceanBookingId"')
+ expect(source).toContain(':port-schedule-id="editingId"')
+ })
})
diff --git a/src/modules/port-schedules/pages/PortSchedulesView.vue b/src/modules/port-schedules/pages/PortSchedulesView.vue
index b38976a8..4085e2e0 100644
--- a/src/modules/port-schedules/pages/PortSchedulesView.vue
+++ b/src/modules/port-schedules/pages/PortSchedulesView.vue
@@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
+import CarrierScheduleQueryPanel from '../components/CarrierScheduleQueryPanel.vue'
import { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleView } from '../types'
@@ -63,7 +64,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
{{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}{{ formatDateTime(row.schedule.observedAt) }}{{ formatDateTime(row.schedule.updatedAt) }}{{ row.expired ? '已过期' : '有效' }}
- {{ customersError.message }}重新加载客户
{{ bookingsError.message }}重新加载订舱
+ {{ customersError.message }}重新加载客户
{{ bookingsError.message }}重新加载订舱
@@ -72,6 +73,6 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
-
+
-
+
diff --git a/src/modules/port-schedules/types.ts b/src/modules/port-schedules/types.ts
index be01b78a..22542157 100644
--- a/src/modules/port-schedules/types.ts
+++ b/src/modules/port-schedules/types.ts
@@ -32,3 +32,91 @@ export interface PortSchedulePage { records: PortScheduleView[]; page: number; s
export interface PortScheduleQuery { page: number; size: number; customerId?: string; keyword?: string; expired?: boolean }
export type PortScheduleData = Omit
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
+
+export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'SUCCESS'
+
+export interface CarrierScheduleQueryResource {
+ id: string
+ queryNo: string
+ customerId: string
+ oceanBookingId: string
+ containerBookingId?: string | null
+ carrierCode: string
+ trackingType: string
+ trackingNo: string
+ officialUrl?: string | null
+ accessMode?: string | null
+ status: CarrierScheduleQueryStatus
+ windowStartedAt?: string | null
+ attemptCount: number
+ nextAllowedAt?: string | null
+ lastAttemptAt?: string | null
+ lastSuccessAt?: string | null
+ sourceUpdatedAt?: string | null
+ expiresAt?: string | null
+ resultPortScheduleId?: string | null
+ lastAttemptStatus?: string | null
+ manualReason?: string | null
+ lastErrorCode?: string | null
+ lastErrorMessage?: string | null
+ remark?: string | null
+ createdAt?: string | null
+ updatedAt?: string | null
+ version: number
+}
+
+export interface CarrierScheduleQueryView {
+ query: CarrierScheduleQueryResource
+ expired: boolean
+ maxAttempts: number
+ nextAllowedAt?: string | null
+}
+
+export interface CarrierScheduleQueryAttempt {
+ id: string
+ queryId: string
+ windowAttemptNo: number
+ carrierCode: string
+ accessMode?: string | null
+ officialUrl?: string | null
+ attemptStatus: string
+ coverageGap?: boolean | null
+ coverageGapCode?: string | null
+ manualReason?: string | null
+ errorCode?: string | null
+ errorMessage?: string | null
+ operatorName?: string | null
+ createdAt?: string | null
+}
+
+export interface CarrierScheduleQueryHistory {
+ id: string
+ queryId: string
+ action: string
+ fromVersion?: number | null
+ toVersion?: number | null
+ reason?: string | null
+ operatorName?: string | null
+ createdAt?: string | null
+}
+
+export interface CarrierScheduleQueryDetail {
+ query: CarrierScheduleQueryView
+ attempts: CarrierScheduleQueryAttempt[]
+ histories: CarrierScheduleQueryHistory[]
+}
+
+export interface CarrierScheduleQueryPage {
+ records: CarrierScheduleQueryView[]
+ page: number
+ size: number
+ total: number
+ pages: number
+}
+
+export interface CarrierScheduleQueryFilter {
+ page: number
+ size: number
+ oceanBookingId?: string
+ containerBookingId?: string
+}
diff --git a/src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md b/src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md
new file mode 100644
index 00000000..32ee4f62
--- /dev/null
+++ b/src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md
@@ -0,0 +1,11 @@
+# 海外合作仓类型契约缺口
+
+四期原型把海外合作仓作为现有仓库主数据中的一种仓库类型,而不是独立库存领域。
+
+- 建议类型码:`OVERSEAS_PARTNER`。
+- 当前状态:仅为前端与后端对齐用的建议值,尚未生效。
+- 当前 Warehouse Service 的 DTO、校验与 OpenAPI 只接受 `DOMESTIC`、`FBA`、`OVERSEAS`。
+- 在服务端正式接受并返回合作仓类型前,Admin 不得把合作仓按 `OVERSEAS` 创建或更新,也不新增一套重复的合作仓列表。
+- 合作仓列表和详情继续复用 `platform:warehouse:list`;未来创建、编辑、启停分别复用现有 `platform:warehouse:create`、`platform:warehouse:edit`、`platform:warehouse:status`。
+- 服务端契约补齐时需同步核对仓库 DTO、Controller、OpenAPI、初始化 SQL、导入模板及全部消费者。
+