diff --git a/AGENTS.md b/AGENTS.md index af42e12..f59d055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ | 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | -| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 | +| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | +| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | | 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | ## 租户、认证与数据安全 @@ -96,14 +97,14 @@ pnpm build ## 全量验收脚本 -- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。 +- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。 - 入口通过测试环境 HTTP 验证注册/审核、客户隔离、订单、入库计费、文件、财务和权限链路,会创建或修改测试数据,必须获得当前对话明确授权。 - `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Client 服务已完成全量验收。 - HTTP 验收通过后,由人工使用开发验收账号逐页检查真实接口调用、客户可见字段、原型、权限、错误态、空态和 `1920×1080` 桌面布局,并将截图发送给 AI 分析;未收到人工截图时标记为待人工确认,不追加 AI 截图或窄屏浏览器验收。 ## Client 全量验收快速索引 -当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。 +当前按客户侧全量业务闭环验收。业务域文档用于核对功能来源和验收证据,不限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。 | 验收域 | Client 范围与代码入口 | 验收首读 | |---|---|---| diff --git a/README.md b/README.md index 91bac4a..74d02aa 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pnpm test 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 +订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经海外 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 @@ -45,6 +45,6 @@ pnpm test 企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证为必填资质,货代资质证书和银行开户许可证为选填资质,均使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB;两项必填资质齐全后可显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 -头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 +头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地订单与客户验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。 diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 239316c..e6bf65b 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -19,12 +19,12 @@ - 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。 - 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。 -## 一期/二期/三期契约索引 +## 订单与客户/入库测量与计费/国内货柜履约契约索引 -- 一期共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。 -- 二期仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。 -- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。 +- 订单与客户共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。 +- 入库测量与计费仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。 +- 国内货柜履约仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/国内货柜履约/国内货柜履约开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。 定位规则:从 `src/index.ts` 的导出开始,反查模块 `api`、store 和 `contracts.ts` 消费者;字段含义不明确时回到服务端 DTO/OpenAPI,不在 shared 中扩展业务语义。 -阶段文档入口(路径相对本文件所在目录):一期 `../../../docs/一期/README.md`;二期 `../../../docs/二期/README.md` 与 `../../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;三期 `../../../docs/三期/README.md`、`../../../docs/三期/三期开发规划.md` 和 `../../../docs/三期/三期需求确认表.md`。范围与契约以阶段确认和设计文档为准,原型只作补充。 +业务域文档入口(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/README.md`;入库测量与计费 `../../../docs/入库测量与计费/README.md` 与 `../../../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`;国内货柜履约 `../../../docs/国内货柜履约/README.md`、`../../../docs/国内货柜履约/国内货柜履约开发规划.md` 和 `../../../docs/国内货柜履约/国内货柜履约需求确认表.md`。范围与契约以正式确认和设计文档为准,原型只作补充。 diff --git a/src/components.d.ts b/src/components.d.ts index 90a9533..723bdea 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -19,6 +19,7 @@ declare module 'vue' { DataTable: typeof import('./components/common/DataTable.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts new file mode 100644 index 0000000..3a355f8 --- /dev/null +++ b/src/modules/cross-border/api/businessOrders.ts @@ -0,0 +1,164 @@ +import { overseasApiRequest } from '@/api/overseasHttp' + +export interface PageResult { + records: T[] + page: number + size: number + total: number + pages: number +} + +export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard' + +export interface BusinessOrderQuery { + page: number + size: number + orderNo?: string + status?: string + routeKeyword?: string + productName?: string + purchaseCategory?: string + transportType?: string + transportMode?: string + requestedFrom?: string + requestedTo?: string + createdFrom?: string + createdTo?: string +} + +export interface ProcurementSummary { + orderNo: string + purchaseCategory: string + productName: string + productSpecification: string | null + purchaseQuantity: number + budgetUnitPrice: number | null + currency: string | null + inboundQuantity: number + outboundQuantity: number + inventoryQuantity: number + status: string + version: number + createdAt: string + updatedAt: string +} + +export interface ProcurementDetail { + summary: ProcurementSummary + productUrl: string | null + receivingAddress: string | null + contactName: string + contactPhone: string + domesticCarrier: string | null + domesticTrackingNo: string | null + overseasCarrier: string | null + overseasTrackingNo: string | null + estimatedArrivalAt: string | null + remark: string | null + reviewRemark: string | null +} + +export interface ProcurementCreate { + purchaseCategory: string + productName: string + productSpecification?: string + productUrl?: string + purchaseQuantity: number + budgetUnitPrice?: number + currency?: string + receivingAddress?: string + contactName: string + contactPhone: string + remark?: string + idempotencyKey: string +} + +export interface TransportSummary { + orderNo: string + transportType: string + transportMode?: string + palletRequired?: string + origin: string + destination: string + containerType: string | null + palletCount?: number + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount: number | null + currency: string | null + contactName: string | null + contactInfo: string | null + requestedDate: string | null + status: string + version: number + createdAt: string + updatedAt: string +} + +export interface TransportDetail { + summary: TransportSummary + originAddress: string | null + destinationAddress: string | null + remark: string | null +} + +export interface TransportCreate { + transportType: string + transportMode?: string + palletRequired?: string + origin: string + originAddress?: string + destination: string + destinationAddress?: string + containerType?: string + palletCount?: number + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount?: number + currency?: string + contactName?: string + contactInfo?: string + requestedDate?: string + remark?: string + idempotencyKey: string +} + +export interface TransportUpdate extends Omit { + version: number +} + +const endpointByKind: Record = { + procurement: '/merchant/v1/last-leg/overseas-procurements', + trucking: '/merchant/v1/last-leg/us-truck-orders', + looseboard: '/merchant/v1/last-leg/local-looseboard-orders', +} + +function normalizePage(data: PageResult): PageResult { + return { ...data, page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) } +} + +export function listBusinessOrders(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) { + return overseasApiRequest>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage) +} + +export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: string) { + return overseasApiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) +} + +export function createProcurement(payload: ProcurementCreate) { + return overseasApiRequest({ method: 'POST', url: endpointByKind.procurement, data: payload }) +} + +export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { + return overseasApiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) +} + +export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) { + return overseasApiRequest({ + method: kind === 'trucking' ? 'PATCH' : 'PUT', + url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`, + data: payload, + }) +} diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs new file mode 100644 index 0000000..0c0a0ed --- /dev/null +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') +const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') +const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') + +test('activates all three customer cross-border pages with frozen permissions', () => { + for (const [path, permission] of [ + ['cross-border/procurement', 'merchant:cross-border:procurement'], + ['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking'], + ['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping'], + ]) { + assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`)) + } + assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i) +}) + +test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => { + assert.match(api, /overseas-procurements/) + assert.match(api, /us-truck-orders/) + assert.match(api, /local-looseboard-orders/) + assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/) + assert.match(view, /const stableSubmitKey = submitKey\.value \|\| idempotencyKey\(\)/) + assert.match(view, /submitKey\.value = stableSubmitKey/) + assert.match(view, /idempotencyKey: stableSubmitKey/g) +}) + +test('covers list detail create loading error empty pagination and actions-first contracts', () => { + assert.match(view, /mode\.value = 'detail'/) + assert.match(view, /mode\.value = 'list'/) + assert.match(view, /mode = 'create'/) + assert.match(view, /v-loading="loading"/) + assert.match(view, /type="error"/) + assert.match(view, /empty-text="暂无订单"/) + assert.match(view, /]*fixed="left"/) +}) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue new file mode 100644 index 0000000..1969379 --- /dev/null +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/src/modules/cross-border/routes.ts b/src/modules/cross-border/routes.ts index e0a1c4e..1d54e92 100644 --- a/src/modules/cross-border/routes.ts +++ b/src/modules/cross-border/routes.ts @@ -9,7 +9,7 @@ export const crossBorderModule = { path: 'cross-border/procurement', name: 'cross-border-procurement', component: () => import('./views/ProcurementView.vue'), - release: 'prototype', + release: 'active', meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' }, navigation: { key: 'cross-border-procurement', icon: Package }, }, @@ -17,7 +17,7 @@ export const crossBorderModule = { path: 'cross-border/domestic-trucking', name: 'cross-border-domestic-trucking', component: () => import('./views/DomesticTruckingView.vue'), - release: 'prototype', + release: 'active', meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' }, navigation: { key: 'cross-border-domestic-trucking', icon: Truck }, }, @@ -25,7 +25,7 @@ export const crossBorderModule = { path: 'cross-border/pallet-shipping', name: 'cross-border-pallet-shipping', component: () => import('./views/PalletShippingView.vue'), - release: 'prototype', + release: 'active', meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' }, navigation: { key: 'cross-border-pallet-shipping', icon: Ship }, }, diff --git a/src/modules/cross-border/views/DomesticTruckingView.vue b/src/modules/cross-border/views/DomesticTruckingView.vue index d3ecc7e..bc9e623 100644 --- a/src/modules/cross-border/views/DomesticTruckingView.vue +++ b/src/modules/cross-border/views/DomesticTruckingView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/cross-border/views/PalletShippingView.vue b/src/modules/cross-border/views/PalletShippingView.vue index e5cec3f..0f4ee30 100644 --- a/src/modules/cross-border/views/PalletShippingView.vue +++ b/src/modules/cross-border/views/PalletShippingView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index edb2695..bde268f 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/dashboard/views/DashboardView.test.mjs b/src/modules/dashboard/views/DashboardView.test.mjs index ba777db..67c6f0c 100644 --- a/src/modules/dashboard/views/DashboardView.test.mjs +++ b/src/modules/dashboard/views/DashboardView.test.mjs @@ -25,12 +25,15 @@ test('isolates recoverable dashboard failures without hiding successful sections assert.doesNotMatch(source, /