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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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
-
{{ 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、导入模板及全部消费者。 +