diff --git a/AGENTS.md b/AGENTS.md index 29825dce..45816340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,9 @@ 2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。 Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。 3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。 -4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/统一认证与服务通信契约.md`。 +4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。 -普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。累计超过约 250 行时改用定向检索,不全文加载无关专题。 +普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。 ## 项目定位与技术基线 @@ -60,7 +60,7 @@ | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | | 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 | | 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | -| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | +| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | @@ -75,7 +75,7 @@ | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 | | 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 | -全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外仓业务开发先读 `../../docs/海外仓业务/README.md`。进度、排期或验收状态问题再读取进度表和报告。 +全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。 散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。 @@ -88,6 +88,7 @@ | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | | 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` | | 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` | +| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` | ## 业务、安全与共享装配 diff --git a/Jenkinsfile b/Jenkinsfile index 695afd69..0004b5cb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,15 +1,16 @@ pipeline { - agent { label 'java-test' } + agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" } options { timestamps() disableConcurrentBuilds() } stages { stage('检查测试发布参数') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { script { - if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) { - error('当前仅支持 test,请使用测试环境 Job') + if (env.JOB_BASE_NAME?.endsWith('-dev')) { + error('此流水线仅支持 test 和本轮授权的 prod 灰度') } env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } @@ -23,6 +24,7 @@ pipeline { } } stage('构建并部署测试前端') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu docker compose -f docker-compose.test.yml build --pull=false @@ -31,8 +33,30 @@ pipeline { ''' } } + stage('发布临时生产灰度') { + when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } } + steps { + lock(resource: 'firstleg-production-admin-web-deploy') { + withCredentials([ + usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'), + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + sh 'sh scripts/deploy-production.sh' + } + } + } + } } post { - failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } + failure { + script { + if (!env.JOB_BASE_NAME?.endsWith('-prod')) { + sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' + } + } + } } } diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 00000000..25382b1d --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,23 @@ +name: firstleg-prod-admin-web +services: + admin-web: + image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}" + container_name: firstleg-prod-admin-web + restart: unless-stopped + ports: + - "127.0.0.1:${HOST_PORT:-29011}:80" + environment: + CLIENT_WEB_URL: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" + API_UPSTREAM: "http://gateway:9000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + networks: + - backend +networks: + backend: + external: true + name: firstleg-production-network diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 00000000..c1169e7e --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu +set +x +: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" +: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}" +: "${TARGET_SSH_KEY:?}" +: "${TARGET_SSH_USER:?}" +: "${TARGET_SSH_HOST:?}" +: "${TARGET_SSH_KNOWN_HOSTS:?}" +: "${BUILD_REGISTRY_USER:?}" +: "${BUILD_REGISTRY_PASSWORD:?}" +: "${REGISTRY_USER:?}" +: "${REGISTRY_PASSWORD:?}" +case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac +# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters. +printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$' +printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$' +git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +IMAGE_TAG=$(git rev-parse --short=12 HEAD) +registry=${PRODUCTION_IMAGE_REPOSITORY%%/*} +image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG" +TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST" +remote_dir=/data/firstleg/deploy/gray-admin-web +run_ssh() { + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@" +} +run_scp() { + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@" +} +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" +docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + --build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" . +docker push "$image" +run_ssh install -d -m 750 "$remote_dir" +run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml" +# The remote env file contains only image coordinates and public web origin, never credentials. +printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \ + "$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'" +# Registry credentials exist only in an isolated temporary Docker config, removed after pull. +printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$' +run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image" +run_ssh sh -s -- "$remote_dir" <<'REMOTE' +set -eu +cd "$1" +docker network inspect firstleg-production-network >/dev/null +docker compose --env-file release.env -f compose.yml config --quiet +docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120 +docker compose --env-file release.env -f compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz +REMOTE diff --git a/scripts/pull-production-image.sh b/scripts/pull-production-image.sh new file mode 100644 index 00000000..91562c38 --- /dev/null +++ b/scripts/pull-production-image.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +set +x +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +docker login -u "$1" --password-stdin "$2" +docker pull "$3" diff --git a/src/app/components/AdminSidebarMenu.test.ts b/src/app/components/AdminSidebarMenu.test.ts index 0c1babf0..ba96e2ff 100644 --- a/src/app/components/AdminSidebarMenu.test.ts +++ b/src/app/components/AdminSidebarMenu.test.ts @@ -63,10 +63,12 @@ describe('AdminSidebarMenu', () => { expect(groupTitles).not.toContain('供应商中心') expect(groupTitles).toContain('通用') expect(groupTitles).toContain('系统管理') + expect(groupTitles).not.toContain('预约管理') expect(groupTitles).not.toContain('财务管理') + expect(paths).not.toContain('/finance/customer-accounts') expect(paths).toContain('/dashboard') expect(paths).toContain('/orders') - expect(paths).toContain('/master-data/express-companies') + expect(paths).not.toContain('/master-data/express-companies') expect(paths).not.toContain('/orders/first-leg') expect(paths).not.toContain('/first-leg-pricing/customer-rules') expect(paths).toContain('/downloads') diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index 95e75cc5..e8898056 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -241,4 +241,101 @@ describe('admin auth session', () => { expect(auth.refreshToken).toBe('') expect(localStorage.getItem('cc-admin-token')).toBeNull() }) + + it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => { + const previous = token() + const rotated = token({ token_version: 2 }) + localStorage.setItem('cc-admin-token', previous) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) + .mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) + .mockResolvedValueOnce({ code: '200', data: null }) + const auth = useAuthStore() + + await expect(auth.logout()).resolves.toBeUndefined() + + expect(mocks.post).toHaveBeenCalledTimes(3) + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' }) + expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, { + headers: { Authorization: `Bearer ${rotated}` }, + }) + expect(auth.isAuthenticated).toBe(false) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled() + }) + + it('revokes a persisted refresh session when startup already removed its expired access token', async () => { + localStorage.setItem('cc-admin-token', token({ exp: 1 })) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const rotated = token() + mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) + .mockResolvedValueOnce({ code: '200', data: null }) + const auth = useAuthStore() + expect(auth.token).toBe('') + + await auth.logout() + + expect(mocks.post).toHaveBeenCalledTimes(2) + expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' }) + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, { + headers: { Authorization: `Bearer ${rotated}` }, + }) + expect(auth.isAuthenticated).toBe(false) + }) + + it('leaves a subsequent login intact while finishing revocation of the previous session', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + let finishRefresh: ((value: ReturnType) => void) | undefined + const started = new Promise(resolve => { + mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) + .mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) }) + }) + const auth = useAuthStore() + const loggingOut = auth.logout() + // Attach immediately so a regression rejecting early does not become an unhandled rejection. + const result = loggingOut.then(() => null, error => error) + await Promise.race([started, loggingOut.catch(() => undefined)]) + expect(finishRefresh).toBeTypeOf('function') + const newAccess = token({ user_id: '1000002', username: 'second' }) + mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login')) + .mockResolvedValueOnce({ code: '200', data: null }) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + await auth.login('second', 'password') + const revocationAccess = token({ token_version: 3 }) + finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only')) + + expect(await result).toBeNull() + expect(auth.token).toBe(newAccess) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login') + expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, { + headers: { Authorization: `Bearer ${revocationAccess}` }, + }) + }) + + it('does not refresh or retry an ambiguous logout transport failure', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const failure = { isAxiosError: true, response: { status: 503 } } + mocks.post.mockRejectedValue(failure) + const auth = useAuthStore() + await expect(auth.logout()).rejects.toBe(failure) + expect(mocks.post).toHaveBeenCalledTimes(1) + expect(auth.isAuthenticated).toBe(false) + }) + + it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const unauthorized = { isAxiosError: true, response: { status: 401 } } + mocks.post.mockRejectedValueOnce(unauthorized) + if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse()) + mocks.post.mockRejectedValueOnce(unauthorized) + const auth = useAuthStore() + await expect(auth.logout()).rejects.toBe(unauthorized) + expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + }) }) diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 24099027..2c206da9 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -1,5 +1,6 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' +import { isAxiosError } from 'axios' import type { CurrentUser } from '@chongchong/shared' import { http, @@ -157,9 +158,29 @@ export const useAuthStore = defineStore('admin-auth', () => { } async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) { - await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) + async function revoke(access: string, refresh: string) { + const response = await http.post, ApiEnvelope>( + '/auth/logout', { refreshToken: refresh }, { headers: { Authorization: `Bearer ${access}` } }, + ) + unwrapApiResponse(response) + } + if (accessToken) { + try { + await revoke(accessToken, refreshTokenToRevoke) + return + } catch (error) { + if (!isAxiosError(error) || error.response?.status !== 401) throw error + } + } + // Logout has already cleared local state. Refresh only this captured pair + // for revocation; never apply it to this tab or overwrite a subsequent login. + const response = await http.post, ApiEnvelope>( + '/auth/refresh', { refreshToken: refreshTokenToRevoke }, + ) + const rotated = unwrapApiResponse(response) + sessionFromToken(rotated.accessToken) + if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据') + await revoke(rotated.accessToken, rotated.refreshToken) } async function login(account: string, password: string) { @@ -245,7 +266,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const accessToken = token.value const tokenToRevoke = refreshToken.value clearSession() - if (accessToken && tokenToRevoke) { + if (tokenToRevoke) { await revokeTokenPair(accessToken, tokenToRevoke) } } diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index e8eeda8e..51914352 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,80 +8,30 @@ import { } from './adminNavigation' describe('adminNavigation', () => { - it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => { - expect(adminNavigation.map((item) => item.title)).toEqual([ - '首页数据看板', - '客户管理', - '国内仓', - '海外派送', - '货柜管理', - '海外业务', - '海外仓', - '客户服务', - '财务管理', - '销售管理', - '通用', - '系统管理', - ]) - expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([ - '客户列表', - '海外仓阶梯费用', - ]) - expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([ - '订单列表', - '订单测量', - '测量数据审核', - '预装清单', - '库位管理', - '托盘管理', - '整柜业务', - '船期日历', - ]) - expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访']) - expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined() - expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([ - '订舱列表', - '拖车列表', - '货柜列表', - '清关提柜', - ]) - expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ - '尾程订单', - '入库单', - '废弃单', - '库位管理', - '排车列表', - '出库单', - '派送单列表', - '换标单', - '托盘管理', - '库存管理', - '退货库存管理', - '退货箱号管理', - ]) - expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([ - '产品信息', - '供应商列表', - '快递公司', - '往来单位', - '费用项目', - '汇率管理', - '仓库管理', - '海外仓报价', - '船期价格表', - '车辆列表', - '下载中心', - '称重机器', + it('offers the common container menu to overseas-only operators', () => { + const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list'])) + expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic']) + }) + it('matches the latest prototype sidebar using only implemented menu entries', () => { + // 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56). + const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : [])) + const visible = filterAdminNavigation(adminNavigation, allPermissions) + expect(visible.map(node => ({ title: node.title, children: node.children?.map(child => child.title) ?? [] }))).toEqual([ + { title: '首页数据看板', children: [] }, + { title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] }, + { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, + { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] }, + { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, + { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, + { title: '客户服务', children: ['异常订单'] }, + { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, + { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) + expect(visible).toHaveLength(11) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(48) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() - expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ - '用户管理', - '角色权限', - '租户管理', - '品牌设置', - '菜单权限', - 'PDA版本', - ]) }) it('assigns every page a unique path and only shares permissions backed by the API contract', () => { @@ -101,7 +51,7 @@ describe('adminNavigation', () => { expect(paths).toContain('/container-loading/overseas') expect(paths).not.toContain('/container-loading/smart-loading') expect(paths).toContain('/loading-plans') - expect(paths).not.toContain('/loading-lists') + expect(paths).toContain('/loading-lists') expect(paths).not.toContain('/port-schedules') expect(paths).not.toContain('/customs') expect(paths).not.toContain('/outbound-dispatches') @@ -174,7 +124,6 @@ describe('adminNavigation', () => { '/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders', '/overseas-warehouse/exception-orders', - '/overseas-warehouse/customer-visits', '/overseas-warehouse/relabel-orders', '/overseas-warehouse/pallets', '/overseas-warehouse/inventory', @@ -214,12 +163,10 @@ describe('adminNavigation', () => { ])) const paths = getNavigationLeaves(filtered).map((item) => item.path) - expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '通用']) + expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内头程', '通用']) expect(paths).toEqual([ '/dashboard', '/orders', - '/master-data/express-companies', - '/master-data/counterparties', '/warehouses', ]) expect(paths).toContain('/dashboard') @@ -239,11 +186,12 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items']) }) - it('shows the public supplier list only with the list permission', () => { + it('retains the supplier route permission while omitting its entry from the latest sidebar', () => { const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list'])) const missing = filterAdminNavigation(adminNavigation, new Set()) - expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers']) + expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list') + expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard']) expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard']) }) @@ -257,9 +205,9 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', - '/loading-plans', '/container-loading/domestic', - '/container-loading/overseas', + '/loading-lists', + '/loading-plans', ]) }) @@ -335,5 +283,3 @@ describe('adminNavigation', () => { expect(getFirstNavigationLeaf(nodes)?.path).toBe('/tracking') }) }) - - diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 0bc1b7f4..b7e114d3 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -17,6 +17,7 @@ import { } from '@lucide/vue' import { PrototypeContainerIcon, + PrototypeCustomerServiceIcon, PrototypeCustomerIcon, PrototypeDashboardIcon, PrototypeDeliveryIcon, @@ -27,6 +28,7 @@ import { PrototypeOverseasWarehouseIcon, PrototypeSalesIcon, PrototypeSystemIcon, + PrototypeVehicleIcon, } from './prototypeMenuIcons' export interface AdminNavigationNode { @@ -61,17 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [ }, { key: 'domestic-warehouse', - title: '国内仓', + title: '国内头程', icon: PrototypeDomesticWarehouseIcon, children: [ { key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] }, { key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' }, { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' }, - { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, { key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' }, { key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' }, { key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' }, - { key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, + { key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, ], }, { @@ -93,8 +94,40 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' }, { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' }, - { key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' }, - { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list' }, + { key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] }, + { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' }, + { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, + { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true }, + ], + }, + { + key: 'overseas-inventory', + title: '海外尾程', + icon: PrototypeOverseasWarehouseIcon, + children: [ + { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, + { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, + { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, + { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' }, + { key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, + { key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' }, + { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, + { key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, + { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list', hidden: true }, + { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list', hidden: true }, + ], + }, + { + key: 'vehicle-management', + title: '车辆管理', + icon: PrototypeVehicleIcon, + children: [ + { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, + { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, + { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, + { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' }, + { key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, + { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], }, { @@ -107,32 +140,13 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' }, ], }, - { - key: 'overseas-inventory', - title: '海外仓', - icon: PrototypeOverseasWarehouseIcon, - children: [ - { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, - { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, - { key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' }, - { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, - { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, - { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, - { key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, - { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' }, - { key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' }, - { key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, - { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' }, - { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' }, - ], - }, { key: 'customer-service', title: '客户服务', - icon: ContactRound, + icon: PrototypeCustomerServiceIcon, children: [ { key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' }, - { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' }, + { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true }, ], }, { @@ -163,18 +177,17 @@ export const adminNavigation: AdminNavigationNode[] = [ title: '通用', icon: PrototypeGeneralIcon, children: [ - { key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true }, - { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' }, - { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' }, - { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' }, { key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' }, { key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' }, { key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' }, { key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' }, + { key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' }, { key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, - { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' }, - { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' }, + { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true }, + { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true }, + { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true }, + { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true }, ], }, { @@ -187,7 +200,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true }, { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true }, { key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' }, - { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list' }, + { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true }, ], }, ] diff --git a/src/core/navigation/prototypeMenuIcons.ts b/src/core/navigation/prototypeMenuIcons.ts index 20f85ce4..0155e6bd 100644 --- a/src/core/navigation/prototypeMenuIcons.ts +++ b/src/core/navigation/prototypeMenuIcons.ts @@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }), ]) +export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [ + h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }), + h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }), + h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }), +]) + +export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [ + h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }), + h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }), + h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }), +]) + export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [ h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }), h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }), diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 1b8bcffc..c9e5cdba 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -27,13 +27,12 @@ test('test deployment requires an explicit shared network, Gateway and version', assert.match(compose, /external: true/) assert.match(compose, /127\.0\.0\.1:/) assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/) - assert.match(jenkins, /label 'java-test'/) + assert.match(jenkins, /'java-builder' : 'java-test'/) assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) assert.match(jenkins, /docker network inspect/) - assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/) - for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) { - assert.equal(existsSync(resolve(root, file)), false) - } + assert.match(jenkins, /scripts\/deploy-production\.sh/) + assert.match(jenkins, /firstleg-production-ssh/) + }) test('application shell is revalidated and missing chunks remain 404', () => { @@ -65,3 +64,13 @@ test('container build excludes local environment values and waits for Nginx heal assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/) assert.match(jenkins, /up -d --wait --wait-timeout 120/) }) + +test('gray production is isolated from TEST and routes to the single Gateway', () => { + const production = readFileSync(resolve(root, 'docker-compose.production.yml'), 'utf8') + assert.match(production, /name: firstleg-production-network/) + assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(production, /127\.0\.0\.1:/) + assert.match(production, /healthcheck:/) + assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/) + assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/) +}) diff --git a/src/modules/container-loading/api/overseasContainerFees.test.ts b/src/modules/container-loading/api/overseasContainerFees.test.ts index 91e7d955..f69fe437 100644 --- a/src/modules/container-loading/api/overseasContainerFees.test.ts +++ b/src/modules/container-loading/api/overseasContainerFees.test.ts @@ -22,4 +22,8 @@ describe('overseas container fees API', () => { expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' }) expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' }) }) + it('preserves a pending financial cancellation without marking the source voided', async () => { + vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { ...raw, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' } }) + await expect(voidOverseasContainerFee('81', 2, '重复费用', 'void-1')).resolves.toMatchObject({ sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' }) + }) }) diff --git a/src/modules/container-loading/api/overseasContainerFees.ts b/src/modules/container-loading/api/overseasContainerFees.ts index 35e305c3..10979cc6 100644 --- a/src/modules/container-loading/api/overseasContainerFees.ts +++ b/src/modules/container-loading/api/overseasContainerFees.ts @@ -17,6 +17,7 @@ export interface OverseasContainerFeeSource { paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED' reviewReason: string | null; reviewedAt: string | null + voidStatus?: string | null } export interface OverseasContainerFeeSaveRequest { customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER' @@ -39,6 +40,7 @@ function normalizeFee(raw: RawResource): OverseasContainerFeeSource { supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo), paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount), reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt), + voidStatus: text(raw.voidStatus), } } export async function listOverseasContainerFees(containerBookingId: string) { diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts index f00836f6..6eb217d9 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts @@ -37,6 +37,15 @@ describe('尾程付款凭证', () => { mocks.download.mockResolvedValue(new Blob(['image'])) }) + it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => { + mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }]) + const wrapper = await setup() + expect(wrapper.text()).toContain('待财务确认') + expect(wrapper.text()).not.toContain('已作废') + expect(wrapper.findAll('button').some(button => ['调整', '作废'].includes(button.text()))).toBe(false) + wrapper.unmount() + }) + it('显示上传文件名,允许再次上传替换和移除', async () => { const wrapper = await setup() await wrapper.get('button').trigger('click') diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index bd0e05d7..f5ce4469 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -68,6 +68,8 @@ const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [ ] const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value const reviewLabels: Record = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } +const voidLabels: Record = { NOT_REQUESTED: '—', PENDING_FINANCE: '待财务确认', FINANCE_ATTENTION: '待财务处理', CONFIRMED: '已作废' } +function voidPending(row: OverseasContainerFeeSource) { return ['PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(row.voidStatus || '') } const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] }) watch(() => form.customerId, () => { uploadSequence++; voucherUploading.value = false; form.paymentVoucherFileIds = [] }, { flush: 'sync' }) @@ -155,7 +157,7 @@ async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'RE } async function voidFee(row: OverseasContainerFeeSource) { const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' }) - await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废') + await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态') } onMounted(load) @@ -168,7 +170,7 @@ onMounted(load) - + @@ -177,6 +179,7 @@ onMounted(load) + diff --git a/src/modules/container-loading/containerChannelLabel.test.ts b/src/modules/container-loading/containerChannelLabel.test.ts new file mode 100644 index 00000000..bfe0b7fc --- /dev/null +++ b/src/modules/container-loading/containerChannelLabel.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { containerChannelLabel } from './containerChannelLabel' + +describe('container actual channel label', () => { + it('preserves the supplied channel name regardless of booking linkage', () => { + expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: '123' })).toBe('纽约海铁') + expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: null })).toBe('纽约海铁') + }) + it('does not describe a linked booking as unlinked when the channel is absent', () => { + expect(containerChannelLabel({ channelName: null, oceanBookingId: '123' })).toBe('—') + expect(containerChannelLabel({ channelName: '', oceanBookingId: '123' })).toBe('—') + }) + it('shows pending linkage only when both channel and booking are absent', () => { + expect(containerChannelLabel({ channelName: null, oceanBookingId: null })).toBe('待关联订舱') + expect(containerChannelLabel({ channelName: '', oceanBookingId: undefined })).toBe('待关联订舱') + }) +}) diff --git a/src/modules/container-loading/containerChannelLabel.ts b/src/modules/container-loading/containerChannelLabel.ts new file mode 100644 index 00000000..6edb678b --- /dev/null +++ b/src/modules/container-loading/containerChannelLabel.ts @@ -0,0 +1,5 @@ +import type { ContainerBookingResource } from './types' + +export function containerChannelLabel(container: Pick): string { + return container.channelName || (container.oceanBookingId ? '—' : '待关联订舱') +} diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index f297b23f..650f0e5a 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ p vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '
{{ detail?.planNo }}
' }, PalletLabelPrintDialog: { template: '
' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() })) vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() })) -vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) +vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '
' } })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() })) vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() })) @@ -44,6 +44,16 @@ function accessibleText(wrapper: { element: Element } | undefined) { } describe('ContainerDetailView empty return', () => { + it('passes the original container to schedule maintenance and reloads its projection after save', async () => { + const wrapper = await mountDetail('DOMESTIC') + const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }) + expect(panel.props('container').id).toBe('9001') + vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never) + panel.vm.$emit('saved'); await flushPromises() + expect(getContainer).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('REASSIGNED / V2') + expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1') + }) beforeEach(() => { routeState.query = {} routerPush.mockReset() diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index a5a6ef3e..9d3c153f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -39,11 +39,11 @@ describe('ContainerDetailView customs integration', () => { expect(source).toContain(':sections="[\'pda\']"') }) - it('keeps the confirmed loading list inside the domestic container detail only', () => { + it('keeps the container detail entry alongside the standalone manifest', () => { expect(routeSource).toContain("name: 'domestic-container-detail'") expect(routeSource).toContain("name: 'overseas-container-detail'") - expect(loadingRouteSource).not.toContain("name: 'loading-lists'") - expect(navigationSource).not.toContain("title: '装柜清单'") + expect(loadingRouteSource).toContain("name: 'loading-lists'") + expect(navigationSource).toContain("title: '装柜清单'") expect(source).toContain(' { await load(); if (isOverseas.value && route.query.action

货柜信息

-
真实订舱
{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}关联已放舱订舱
计划渠道
{{ plannedChannelName }}
实际渠道
{{ detail.channelName || '待关联订舱' }}
异常状态
{{ detail.exceptionActive ? '异常' : '正常' }}
目的仓
{{ overseasDestinationText }}
拖车公司
{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
+
真实订舱
{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}关联已放舱订舱
计划渠道
{{ plannedChannelName }}
实际渠道
{{ containerChannelLabel(detail) }}
异常状态
{{ detail.exceptionActive ? '异常' : '正常' }}
目的仓
{{ overseasDestinationText }}
拖车公司
{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}

船名航次信息

+
船公司
{{ detail.shippingCompanyName || '—' }}
船名 / 航次
{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
预计开船
{{ formatTime(detail.estimatedSailingAt) }}
预计到港
{{ formatTime(detail.estimatedPortArrivalAt) }}
diff --git a/src/modules/container-loading/pages/ContainerWorkbenchView.test.ts b/src/modules/container-loading/pages/ContainerWorkbenchView.test.ts new file mode 100644 index 00000000..a22489ba --- /dev/null +++ b/src/modules/container-loading/pages/ContainerWorkbenchView.test.ts @@ -0,0 +1,33 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ContainerWorkbenchView from './ContainerWorkbenchView.vue' + +const mocks = vi.hoisted(() => ({ permissions: new Set(), push: vi.fn() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => mocks.permissions.has(permission) }) })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: mocks.push }) })) +vi.mock('./ContainersView.vue', () => ({ default: { props: ['workbench'], template: '
{{ workbench }}
' } })) + +function render(stage: 'DOMESTIC' | 'OVERSEAS' = 'DOMESTIC') { + return mount(ContainerWorkbenchView, { props: { stage }, global: { stubs: { ElButton: { template: '' } } } }) +} + +describe('container stage entry permissions', () => { + beforeEach(() => { mocks.permissions.clear(); mocks.push.mockReset() }) + it('opens overseas operations for an overseas-only user entering the common menu', () => { + mocks.permissions.add('platform:firstleg:terminal-drayage:list') + const wrapper = render() + expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS') + expect(wrapper.text()).not.toContain('国内装柜') + }) + it('preserves a direct overseas visit and allows switching back to domestic', async () => { + mocks.permissions.add('platform:firstleg:terminal-drayage:list') + mocks.permissions.add('platform:firstleg:container:list') + const wrapper = render('OVERSEAS') + expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS') + await wrapper.findAll('button')[0]!.trigger('click') + expect(mocks.push).toHaveBeenCalledWith({ name: 'domestic-container-workbench' }) + }) + it('does not mount data consumers without either list permission', () => { + expect(render().find('[data-testid="workbench"]').exists()).toBe(false) + }) +}) diff --git a/src/modules/container-loading/pages/ContainerWorkbenchView.vue b/src/modules/container-loading/pages/ContainerWorkbenchView.vue new file mode 100644 index 00000000..6da4c4be --- /dev/null +++ b/src/modules/container-loading/pages/ContainerWorkbenchView.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index d8270edb..4a1bd40c 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -2,7 +2,7 @@ import '@/styles/adminDensity.css' import { computed, onMounted, reactive, ref, watch } from 'vue' import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' @@ -23,6 +23,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups' +import { containerChannelLabel } from '../containerChannelLabel' import { getTerminalDrayageEmails, sendTerminalDrayageEmail, @@ -45,6 +46,7 @@ type ContainerForm = ContainerSaveRequest & Pick(), { workbench: 'DOMESTIC' }) const router = useRouter() +const route = useRoute() const access = useAccessPreviewStore() const rows = ref([]) const customers = ref([]) @@ -66,7 +68,7 @@ const page = ref(1) const pageSize = ref(20) const total = ref(0) const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE') -const filters = ref({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] }) +const filters = ref({ customerId: '', keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '', containerType: '', status: '', sailingRange: [] }) const applied = ref({ ...filters.value }) const detail = ref(null) const editorOpen = ref(false) @@ -244,7 +246,7 @@ const allColumns: DataTableColumn[] = [ { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, { key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' }, - { key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => row.channelName || '待关联订舱' }, + { key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => containerChannelLabel(row) }, { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, { key: 'pickupAppointmentAt', label: '预计到仓', width: 210, slotName: 'pickupAppointmentAt' }, { key: 'actualPickupAt', label: '实际到仓', width: 165, slotName: 'actualPickupAt' }, @@ -736,9 +738,9 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) + diff --git a/src/modules/first-leg-orders/api/platformFirstLegOrders.test.ts b/src/modules/first-leg-orders/api/platformFirstLegOrders.test.ts index c141fa9c..2253c25c 100644 --- a/src/modules/first-leg-orders/api/platformFirstLegOrders.test.ts +++ b/src/modules/first-leg-orders/api/platformFirstLegOrders.test.ts @@ -5,7 +5,6 @@ import { acceptPlatformFirstLegOrder, approvePlatformFirstLegOrder, cancelPlatformFirstLegOrder, - completePlatformFirstLegOrder, getPlatformFirstLegOrder, listPlatformFirstLegOrders, startPlatformFirstLegOrder, @@ -82,15 +81,14 @@ describe('platform first-leg order API', () => { await approvePlatformFirstLegOrder('10001', '90001') await acceptPlatformFirstLegOrder('10001', '90001') await startPlatformFirstLegOrder('10001', '90001') - await completePlatformFirstLegOrder('10001', '90001') - await cancelPlatformFirstLegOrder('10001', '90001', '客户取消') + await cancelPlatformFirstLegOrder('10001', '90001', { expectedVersion: 7, reason: '客户取消' }, 'cancel-original-key') expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001', { params: { tenantId: '10001' } }) expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '10001' } }) expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/orders/first-leg/90001/accept', undefined, { params: { tenantId: '10001' } }) expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/orders/first-leg/90001/start', undefined, { params: { tenantId: '10001' } }) - expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/complete', undefined, { params: { tenantId: '10001' } }) - expect(http.post).toHaveBeenNthCalledWith(5, '/platform/v1/orders/first-leg/90001/cancel', { reason: '客户取消' }, { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/cancel', { expectedVersion: 7, reason: '客户取消' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'cancel-original-key' } }) + expect(http.post).toHaveBeenCalledTimes(4) }) it('writes the backend express contract as names, not a guessed master-data ID', async () => { diff --git a/src/modules/first-leg-orders/api/platformFirstLegOrders.ts b/src/modules/first-leg-orders/api/platformFirstLegOrders.ts index 45a9d704..eedfdac4 100644 --- a/src/modules/first-leg-orders/api/platformFirstLegOrders.ts +++ b/src/modules/first-leg-orders/api/platformFirstLegOrders.ts @@ -184,12 +184,17 @@ export function startPlatformFirstLegOrder(tenantId: string, orderId: string) { return postData(`/platform/v1/orders/first-leg/${orderId}/start`, undefined, { tenantId }) } -export function completePlatformFirstLegOrder(tenantId: string, orderId: string) { - return postData(`/platform/v1/orders/first-leg/${orderId}/complete`, undefined, { tenantId }) -} - -export function cancelPlatformFirstLegOrder(tenantId: string, orderId: string, reason: string) { - return postData(`/platform/v1/orders/first-leg/${orderId}/cancel`, { reason }, { tenantId }) +export async function cancelPlatformFirstLegOrder( + tenantId: string, + orderId: string, + payload: { expectedVersion: number; reason: string }, + idempotencyKey: string, +) { + const response = await http.post, ApiEnvelope>( + `/platform/v1/orders/first-leg/${orderId}/cancel`, payload, + { params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) } export function updatePlatformFirstLegOrderExpress( diff --git a/src/modules/iam/utils/permissionTree.ts b/src/modules/iam/utils/permissionTree.ts index 03853b1d..19e3fdd3 100644 --- a/src/modules/iam/utils/permissionTree.ts +++ b/src/modules/iam/utils/permissionTree.ts @@ -102,7 +102,8 @@ export function buildNavigationPermissionTree( } function navigationNode(node: AdminNavigationNode): RolePermissionTreeNode | null { - if (node.hidden) return null + // Hiding a formal sidebar entry must not remove its existing assignable permissions. + if (node.hidden && !node.permission && !node.permissionAnyOf?.length) return null if (node.children?.length) { const children = node.children diff --git a/src/modules/loading-lists/components/LoadingListsView.behavior.test.ts b/src/modules/loading-lists/components/LoadingListsView.behavior.test.ts new file mode 100644 index 00000000..f999c5a9 --- /dev/null +++ b/src/modules/loading-lists/components/LoadingListsView.behavior.test.ts @@ -0,0 +1,55 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LoadingListsView from './LoadingListsView.vue' + +const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn(), replace: vi.fn(), query: {} as Record })) +vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push, replace: mocks.replace }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) })) +vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '
' } })) +vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) })) +vi.mock('../api/platformLoadingLists', () => ({ listConfirmedLoadingLists: mocks.list, listPreloadPlans: mocks.list, getConfirmedLoadingList: mocks.get })) +vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) })) +vi.mock('./LoadingListDetailDrawer.vue', () => ({ default: { props: ['detail', 'error'], template: '
{{ detail?.summary.loadingListNo }}{{ error?.message }}
' } })) + +const rows = [{ planId: '1', warehouseId: '11' }, { planId: '2', warehouseId: '22' }] +function render() { + return mount(LoadingListsView, { props: { mode: 'confirmed' }, global: { stubs: { + AdminPage: { template: '
' }, + ListPageLayout: { template: '
' }, + DataTable: { props: ['data'], template: '
' }, + ElButton: { template: '' }, + } } }) +} +describe('confirmed manifest navigation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.query = {} + mocks.can.mockReturnValue(true) + mocks.list.mockResolvedValue({ records: rows, total: 2 }) + }) + it('opens each manifest on its dedicated detail route with warehouse context', async () => { + const wrapper = render(); await flushPromises() + await wrapper.findAll('button')[0]!.trigger('click') + expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } }) + await wrapper.findAll('button')[2]!.trigger('click') + expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } }) + expect(mocks.get).not.toHaveBeenCalled() + expect(wrapper.find('[data-testid="detail"]').exists()).toBe(false) + }) + it('prints through the dedicated detail page', async () => { + const wrapper = render(); await flushPromises() + await wrapper.findAll('button')[1]!.trigger('click') + expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11', print: '1' } }) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('replaces a list deep link with the dedicated detail route', async () => { + mocks.query = { planId: '2', warehouseId: '22' } + render(); await flushPromises() + expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } }) + }) + it('does not fetch manifests without list permission', async () => { + mocks.can.mockReturnValue(false) + render(); await flushPromises() + expect(mocks.list).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index 2de1242c..ad574499 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -66,8 +66,8 @@ describe('LoadingListsView', () => { expect(confirmedDetailSource).toContain('返回列表') expect(confirmedDetailSource).toContain('router.back()') expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }') - expect(routesSource).not.toContain("path: 'loading-lists/:planId'") - expect(routesSource).not.toContain("name: 'loading-list-detail'") + expect(routesSource).toContain("path: 'loading-lists/:planId'") + expect(routesSource).toContain("name: 'loading-list-detail'") }) it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => { @@ -111,7 +111,7 @@ describe('LoadingListsView', () => { expect(source).toContain('useRoute()') expect(source).toContain('route.query.planId') expect(source).toContain('route.query.warehouseId') - expect(source).toContain("router.replace({ name: 'loading-list-detail'") + expect(source).toContain("await router.replace({ name: 'loading-list-detail'") expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'") expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH') expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())') @@ -139,7 +139,7 @@ describe('LoadingListsView', () => { expect(source).toContain(":class=\"{ 'density-action': isPreload }\"") expect(source).toContain(':type="isPreload ? undefined : \'primary\'" :plain="!isPreload"') expect(source).not.toContain('([]) const customersLoading = ref(false) const query = reactive({ customerId: '', keyword: '', createdRange: [] }) const handledDeepLink = ref('') +function printConfirmed(row: LoadingListSummary) { + void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || ''), print: '1' } }) +} const isPreload = computed(() => props.mode === 'preload') const title = computed(() => isPreload.value ? '预装清单' : '装柜清单') @@ -69,7 +72,7 @@ const columns = computed(() => isPreload.value ? [ { key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 90, align: 'right', slotName: 'grossWeight' }, { key: 'createdAt', label: '创建时间', minWidth: 140, slotName: 'createdAt' }, ] : [ - { key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false }, + { key: 'actions', label: '操作', fixed: 'left', width: 150, slotName: 'actions', hideable: false, movable: false }, { key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 }, { key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 }, @@ -223,6 +226,7 @@ onMounted(() => { diff --git a/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.test.ts b/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.test.ts new file mode 100644 index 00000000..a671ecf0 --- /dev/null +++ b/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.test.ts @@ -0,0 +1,65 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { reactive } from 'vue' +import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue' + +const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record } })) +vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks })) +vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get })) +vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) })) + +function render() { + return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: { + AdminPage: { template: '
' }, + LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '
' }, + } } }) +} +const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] }) + +describe('confirmed loading list detail page', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } }) + mocks.get.mockResolvedValue(completed()) + vi.spyOn(window, 'print').mockImplementation(() => {}) + }) + afterEach(() => vi.restoreAllMocks()) + it('loads read-only detail on the dedicated page and returns to the list', async () => { + const wrapper = render(); await flushPromises() + expect(mocks.get).toHaveBeenCalledWith('1', '11') + const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' }) + expect(detail.props('detail')).toEqual(completed()) + expect(window.print).not.toHaveBeenCalled() + vi.spyOn(window.history, 'length', 'get').mockReturnValue(1) + detail.vm.$emit('back') + expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' }) + wrapper.unmount() + }) + it('prints only after detail has loaded and removes the print request', async () => { + mocks.route.query.print = '1' + const wrapper = render(); await flushPromises() + expect(window.print).toHaveBeenCalledTimes(1) + expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false) + expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } }) + wrapper.unmount() + }) + it('keeps failure visible and does not print when loading fails', async () => { + mocks.route.query.print = '1' + mocks.get.mockRejectedValueOnce(new Error('清单不可用')) + const wrapper = render(); await flushPromises() + expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用') + expect(window.print).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('ignores a stale response after switching manifests', async () => { + let resolveFirst!: (value: unknown) => void + mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })) + .mockResolvedValueOnce(completed('2')) + const wrapper = render() + mocks.route.params.planId = '2' + await flushPromises() + resolveFirst(completed()); await flushPromises() + expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail')).toEqual(completed('2')) + wrapper.unmount() + }) +}) diff --git a/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.vue b/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.vue index 03bd60b8..69c8014a 100644 --- a/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.vue +++ b/src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.vue @@ -1,5 +1,5 @@ @@ -58,6 +70,7 @@ watch([planId, warehouseId], () => void loadDetail()) diff --git a/src/modules/overseas-business/types.ts b/src/modules/overseas-business/types.ts index b9fbeeda..9f276182 100644 --- a/src/modules/overseas-business/types.ts +++ b/src/modules/overseas-business/types.ts @@ -53,6 +53,7 @@ export interface LocalTransportQuery { transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string } export interface LocalTransportOrder { + fleetArrangement: import('./fleetArrangement').FleetArrangement | null id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number @@ -83,13 +84,14 @@ export interface UpdateLocalTransportOrderRequest { export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION' export type PalletRequired = 'YES' | 'NO' -export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED' +export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED' export interface LocalLooseboardQuery { page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string } export interface LocalLooseboardOrder { - id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired + fleetArrangement: import('./fleetArrangement').FleetArrangement | null + id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired | null transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md new file mode 100644 index 00000000..7ffd8cf7 --- /dev/null +++ b/src/modules/overseas-master-data/AGENTS.md @@ -0,0 +1,35 @@ +# 海外通用资料与车辆管理 + +- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。 + +- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。 + +- 车次“在途交接”页签按需挂载`TripHandoverPanel.vue`,沿`fleet-handovers-v1.yaml`读取剩余货物和交接记录;确认用trip:handover,旧资源结束用trip:release,saved刷新车次。原DEPART不重写,司机/车辆任务分别结束。 + +- 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。 + +- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,未知新增结果保留原键及内容重试。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。 + +- 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。 + +- 适用应用规则见 `../../../AGENTS.md`;原型与交互矩阵为本目录 `PROTOTYPE_MATRIX.md`,新增司机页面使用其中用户提供的新版原型入口。 +- 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。 +- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。 +- 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。 +- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,CSV使用车牌业务标识并包含容量字段。 +- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。 +- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。 +- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。 +- 列表与统计使用同一次服务端响应;统计范围与列表筛选一致,30天提醒按证照时区计算。页面不能用当前分页行数替代总统计,也不能在浏览器重算派车资格。 +- 姓名/证照关键字段变化与替换驾驶证重新待审;体检、培训和保险当前仅记录日期,不自动成为必需资质。所属仓库使用真实公共目录,不接受自由文本假关联。 +- `driver:list` 包含详情、历史、资格预览与关联附件读取;创建、编辑、启停、审核、上传权限独立。前端只隐藏/禁用操作,服务端仍需校验。 +- 保存档案成功而上传失败时保留已生成的司机和版本,重试不得再次创建;版本冲突保留表单及服务端错误。审核或修改成功使用返回详情,不无条件再次请求详情。 +- 历史按需分页;附件只在打开预览时读取,关闭/卸载释放Blob URL,忽略已关闭后的迟到响应。文件业务错误不能作为图片/PDF预览。 +- `DriverDetailView` 资格预览随预计结束时间或档案变化失效;只显示待重新校验,不自行构造服务端资格结论。真实派车和发车仍须服务端命令复核。 +- 定向检查:`pnpm test src/modules/overseas-master-data src/core/navigation/adminNavigation.test.ts src/styles/adminDensity.test.ts`;发布前按应用规则执行类型检查、全量测试和构建。 + +- 车次详情“计划容量”页签按需读写`/capacity-plan`,复用`TripLoadingPanel`的计划模式,预计重量/体积与实际装载分别保存;只在该页签展示逐段计划余位。`planValid=false`时不展示旧余位,货物变化后重新核对;保存计划只更新车次版本,不把理货审核变成通过或写入实载。 + +- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。 + +- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。 diff --git a/src/modules/overseas-master-data/CONTRACT_GAPS.md b/src/modules/overseas-master-data/CONTRACT_GAPS.md index e104547b..62988101 100644 --- a/src/modules/overseas-master-data/CONTRACT_GAPS.md +++ b/src/modules/overseas-master-data/CONTRACT_GAPS.md @@ -1,6 +1,6 @@ # 海外通用资料真实接口边界 -三域统一经 `overseasHttp` 使用浏览器业务路径 `/platform/v1/last-leg/master-data`,由海外 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。 +三域统一使用共享 `http` 客户端和浏览器业务路径 `/platform/v1/last-leg/master-data`,经当前统一 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。 - 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。 - 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。 @@ -9,7 +9,7 @@ 当前明确边界: - 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。 -- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。 +- 车辆详情已接保养、加油、维修、故障、救援和异常记录,合同见 `fleet-vehicle-records-v1.yaml`;派送履约及跨车次更换历史尚未接入,页面提示从现有排车/派送管理查看派送记录,不拼装其他订单数据。 - 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。 - 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。 - 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index e4b689ab..303a7d2a 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -1,5 +1,32 @@ # 海外通用资料原型核对矩阵 +## 内部拼车需求池首版 + +依据:用户授权按现有车次样式整合;唯一范围为 [内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7、9节,没有另引入外部原型。 + +| 页面 | 操作 | 确认承载与实现 | +| --- | --- | --- | +| 拼车需求池 | 可接运力、我的意向、查询和分页 | 独立列表页,integrated DataTable,仅激活页签发请求;一行一发布,多站余位在行内分行 | +| 发布详情 | 运输摘要、分段余位、获准意向 | 独立路由;一次详情含首20条意向,后续按需分页;销售不读取车次敏感DTO | +| 意向 | 新增、编辑、只读详情 | 1080px居中三列表单;取消/X/Esc/路由离开保护;客户聚焦搜索和报价显式读取 | +| 意向 | 确认承运、取消 | 现有居中确认框;服务端允许动作与独立权限;未知结果原键原载荷重试 | +| 车次详情 | 发布设置 | 1080px居中三列表单,显式打开才读取;保存、发布、暂停、恢复、关闭各自命令 | +| 车次外接预订 | 池来源编辑/确认/取消 | 只读并提示在池中管理;实际来货/实收/关联排车仍走原履约弹框 | + +本轮用户授权内置浏览器1920×1080验收;自动测试与实际浏览器结果分别记录,未验收状态不推定通过。 + +## 2026-10-07 三个车辆列表样式统一 + +依据:本轮用户提供的司机管理、车次管理、出车记录三张截图;对齐已确认的 `VehiclesView.vue` 连续列表及 `docs/Admin高密度界面规范.md`。仅改列表容器与排版,不改变下列操作承载、权限或请求。 + +| 页面 | 操作与当前承载 | 本轮结论 | +|---|---|---| +| 司机管理 | 查询/到期统计筛选;详情独立路由;新增/编辑1080px居中;证照预览620px居中;启停原因确认;审核在详情 | 列表接入integrated标题、筛选、统计、表格、分页;三项统计保留点击筛选,紧凑排列;各操作承载保持 | +| 车次管理 | 查询;新建1080px计划弹框;详情独立路由;编辑、审核、确认在详情;列表无文件预览/删除 | 接入相同integrated列表,详情操作保留在第一列;弹框与详情保持 | +| 出车记录 | 日期/货源筛选、图表和汇总;导出;车次详情独立路由;实测记录820px居中及其中凭证预览;列表无删除/审核 | 图表、说明与汇总放入连续列表内,统一间距,空图表不重复显示空系列标题;保留未知金额、成本及统计说明 | + +改后视觉结论待用户在1920×1080、100%缩放提供截图确认;自动检查单独记录,不以测试代替视觉通过。 + 事实来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/10-general.html`、`prototype-src/overlays.html`、`prototype-src/app.js`。 | 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 | @@ -13,8 +40,77 @@ | 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` | | 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 | | 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:10437-10456`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 已接真实分页;区域降为查询下拉框,仅保留自有/外派归属页签;操作列按全局约定移到第一列 | 当前对话确认高于原型;查询提交 ownership/region/status,不拼装数据 | -| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 | -| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 | +| 车辆 | 详情 | `10-general.html`的`page-vehicle-detail`、`vehicle-detail-orders-title`(455)、`vehicle-detail-history-title`(459) | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录;只读表不添加操作列 | 已接车辆详情和两个分页只读投影;派送保留单号、BOL/来源、客户、目的地、预约日期、状态和关联关系;车次统一改派按事件展示原/新车辆及司机和关联排车,不虚构逐派送改派事件。操作人姓名未记录时为“—” | 按稳定车辆身份及不可变历史关联,不按车牌猜测;使用派送/车次各自只读权限。真实1920×1080页面及请求量待新接口发布后验收 | +| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529`;本次车辆容量设计见车辆管理规划第4.1节 | 居中弹框 `modal-vehicle-edit` | 原760px两列;新增托位/载重/容积后按高密度规范扩为1080px三列 | 已接创建/编辑、独立状态与导出;三项容量同填或全空,不从车型文本推算 | create/edit/status 权限独立;占用中关键变更由服务端拒绝 | | 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE | -原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。 +旧版原型未定义的车辆文件预览、审核/确认流程不自行补充;新版保养及加油照片按下表的原型链接行为接入。 + +## 新版车辆保养及加油接入矩阵 + +输入与新版司机相同。范围是车辆详情内的保养、加油记录;维修、故障、救援、异常和财务关联尚未在这一增量实现。 + +| 页面/操作 | 原型证据 | 原型承载及尺寸 | 实施口径 | +| --- | --- | --- | --- | +| 车辆详情保养及加油 | `新版.html:9033、9044`,`vehicle-detail-ops`;`22882–22892` | 独立车辆详情内页签,保养/加油两组表格 | 按当前车辆分别分页,进入页签才请求,不把其他车记录带入;操作列第一列 | +| 新增/编辑保养与加油 | `8937` 的 `modal-vehicle-ops-record`、`22903–22916` | 680px 居中弹框,两列表单,底部取消/保存 | 沿用字段分组;明确里程及加油单位,实际时间带时区提交;更正保留原因与版本,不自动改车辆主档里程 | +| 查看记录图片 | `22891–22892` 的 `target="_blank"` 图片链接 | 新标签页查看图片 | 点击时鉴权下载已关联图片,以临时Blob打开,关闭释放;不把JSON错误当图片,不新增预览抽屉 | +| 删除 | `22917` 附近 `deleteVehicleOpsRecord`,浏览器confirm | 删除确认 | 删除/作废语义仍待业务明确,本增量不注册删除端点或展示假动作 | +| 核验状态 | `22906` 加油表单及车辆保养表单 | 表单内字段 | 保存人工核验状态,不替代财务付款或车辆可派资格;不增加审批工作流 | + +真实1920×1080视觉和交互验收待执行;接口与自动测试分别记录,不视为视觉通过。 + +## 新版维修、故障、救援及异常接入矩阵 + +| 页面/操作 | 原型证据 | 原型承载及尺寸 | 实施口径 | +| --- | --- | --- | --- | +| 维修、紧急故障、应急救援、异常列表 | `新版.html:9045–9048` 的四个 `vehicle-detail-*` 分区 | 车辆详情内四个页签,各自表格 | 只查询当前车辆和当前类型,切换时忽略旧响应;不把原型全局数组当作跨车共享记录 | +| 四类新增 | `8938` 的 `modal-vehicle-detail-add`、`23275` 的 `openVehicleDetailAdd` | 620px居中弹框,日期/处理人、描述、费用/状态 | 按类型明确字段;维修/救援项目与地点单独输入并保存,修复原型读写错配;金额含明确币种,处理状态与付款信息分开 | +| 异常编辑 | `23270–23273` 的 `editVehicleException`,`9048` 行操作 | 复用620px居中弹框 | 保留版本与更正原因;操作列按全局要求放第一列 | +| 维修/故障/救援更新处理 | 三类原型仅新增;2026-10-02 用户同意按建议出首版 | 列表“更新处理”,复用620px原弹框 | 已开放独立编辑权限的更新处理;更正说明选填,未填记录标准操作说明 | +| 费用与付款 | `9047` 付款状态列与原弹框处理状态混用 | 付款为列表只读信息 | 当前无财务关联时服务端明确返回UNLINKED,显示“未关联财务”;不把COMPLETED映射为已付款,不显示无来源实付金额 | +| 图片、删除 | 四类弹框未定义图片或删除入口 | 未定义 | 本增量不为这四类新增图片弹框、删除按钮或作废动作 | + +以上仅为代码接入依据,真实界面验收仍待执行。 + +## 新版报价与出车记录接入 + +| 入口 | 原型依据 | 当前承载 | 核对边界 | +| --- | --- | --- | --- | +| 车队报价 | `新版.html:8989–9021`,通用菜单`1527` | 独立列表,780px居中编辑,1040px居中CSV导入;采购/销售分开 | 列表权限含详情历史,写入与导出独立;整批导入,未知价不转零;真实1920×1080截图待发布后确认 | +| 出车记录 | `新版.html:8941–8987`,车辆菜单`1457` | 独立列表与统计,同一实际发车筛选口径,详情链接沿车次权限 | 不用原型内存记录创建发车事实;成本/财务未查明时明确显示未查询;首版CSV导出不声称原生Excel工作簿,页面与请求量待1920×1080真实验收 | +| 出车实测凭证 | `新版.html:8941–8987`、`modal-vehicle-trip`(8966);车辆规划第4.1节的实测凭证补充 | 沿现有820px居中实测弹框,附件选填;PDF/JPG/PNG,单个不超过10MiB、最多20个 | 平台0+专用业务类型+车次归属校验;保存后当前及历史版本按需预览,不逐行GET;零附件合法,移除当前引用保留历史。上传不创建实测记录;未知上传结果停止重传并保留文件名及诊断,允许暂不绑定。迟到响应不串车次;1920×1080、100%缩放视觉与请求量待真实验收 | + +## 新版司机管理接入矩阵 + +输入为用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-09/新版.html`,哈希及业务确认见 `docs/海外仓业务/车辆管理原型梳理与任务规划.md`。下列为本次实现依据,真实页面验收尚待执行。 + +| 页面 | 操作 | 原型证据 | 原型承载方式 | 尺寸/布局与实施结论 | +| --- | --- | --- | --- | --- | +| 司机管理 | 列表/筛选 | `8798–8805`,`page-driver-management` | 独立列表页,三张可见KPI | 保留总数、30天到期、已过期;共用服务端筛选统计,操作列按全局约定移至第一列;状态拆分为启停、审核和证照有效期,不伪造任务中状态 | +| 司机管理 | 详情 | `8807–8813`,`page-driver-management-detail` | 独立详情页 | 基础信息、驾驶资质、证照与派车状态;使用正式详情路由,连续四列字段与返回入口 | +| 司机管理 | 新增/编辑 | `8815`,`modal-driver-edit` | 居中弹框,原宽820px | 多字段按高密度规范使用1080px三列表单,保留字段分组;可先保存待补件档案,证照上传失败可重试,不重复创建司机;审核状态改由独立命令维护 | +| 司机管理 | 文件预览 | `8816`,`modal-driver-license-view` | 620px居中弹框 | 真实图片/PDF预览,当前或历史已关联附件鉴权读取,关闭即释放临时预览地址 | +| 司机管理 | 审核/打回 | 当前对话确认“详情展示、点击通过或者打回” | 原型无独立审核弹框 | 在详情的证照区域显示操作、意见与处理结果;打回必填原因,独立review权限;不在编辑表单里选择已审核 | +| 司机管理 | 启停 | 原型删除使用浏览器confirm;停用为本次明确的服务端命令 | 居中确认 | 保留档案与历史,启停填写原因;不将停用描述成物理删除 | + +## 共享车次新增交互(2026-10-02 用户确认首版方案) + +成本工作台沿车辆规划第4.1节“一趟直接成本及自有/外接分摊”的已确认方案,在车次详情增加“直接成本”页签。费用登记/更正使用680px居中两列表单,历史使用1080px居中只读表;原型出车记录的笼统价格不直接当成本或利润。分页查询同时返回服务端同币种汇总和基于实运托位的分摊,未知值明确显示未登记/暂未分摊。对应`TripCostPanel.vue`及`tripCosts.ts`;真实1920×1080页面、交互和请求量待结构准备及发布后验证。 + +业务依据为当前车辆管理规划第4.1节。原型出车记录 `新版.html:8941–8987`、`modal-vehicle-trip`(8966)仅展示记录、统计和新增/编辑弹框,没有共享车次关联及实际装载工作台。用户已明确“按你的建议执行,先做一个版本再优化”,采用独立车次列表与详情页,计划居中弹框,分配/关联/装载在详情分区,结案和资源释放分别确认。首版普通说明选填,不增加格式校验清单;保留权限、归属、版本、容量和证照期限门禁。 + +| 页面/操作 | 原型证据 | 建议承载 | 当前结论 | +| --- | --- | --- | --- | +| 车次列表、详情 | 出车记录列表;无共享车次详情定义 | 独立列表与详情页,按已确认密度规范排版 | 已按用户确认接入首版;真实页面验收待执行 | +| 创建、编辑计划 | 出车记录新增/编辑为 `modal-vehicle-trip`;新计划语义另见业务确认 | 居中计划弹框,路线表格整行 | 已按确认方案接入计划弹框;不将原型手填统计值当作计划字段 | +| 分配车辆司机、关联/解除排车 | 新业务规则,无原型对应操作 | 详情页内分区 | 已按用户确认接入首版;真实页面验收待执行 | +| 调度/理货审核 | 原型有详情、通过及打回;已确认调度先于拣货、理货先于发车 | 关联排车行独立审核,实际装载分区整车理货审核,打回居中填写原因 | 首版已接入独立权限和服务端审核状态;真实页面验收待执行 | +| 外接询价、详情与承运占位 | 用户确认运营后台登记外接业务;首版不扩展客户在线撮合 | 车次详情外接页签、1080px居中三列表单,详情只读、确认/取消居中确认;操作列随计划/执行阶段使用320/170px,保留原行操作 | 已接登记/编辑/确认/取消、独立权限及已确认预订余位;已接真实来货登记、实收复核和排车关联。2026-10-03截图`fleet-external-booking-1920.png`及DOM复现旧440px操作列使1640px详情容器内表格宽1720px;本次仅收窄闲置列宽,部署后的1920×1080复验仍待完成 | +| 计划容量与剩余运力 | 本轮外接剩余运力方案,原型无完整占位工作台 | 车次详情独立页签,复用托盘核对表,显示预计值和每段剩余托位/kg/m³ | 源码已接计划容量;已扣除确认承运的外接占位;不把预订当实物装载 | +| 实际装载确认 | 新业务规则,无原型对应操作 | 详情页内实物托盘表及确认区 | 已按用户确认接入首版;真实页面验收待执行 | +| 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立。原实发、实签、本趟实退及未结箱数分别展示,混合结果显示“余货已签收”;一次结案查询提供数量,不逐票补请求,1920×1080真实视觉待验 | + +| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 | + +| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 | diff --git a/src/modules/overseas-master-data/api/capacityOffers.test.ts b/src/modules/overseas-master-data/api/capacityOffers.test.ts new file mode 100644 index 00000000..f590acf3 --- /dev/null +++ b/src/modules/overseas-master-data/api/capacityOffers.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, listOfferQuotes, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers' +import type { CapacityOffer, CapacityInquiry, InquiryWrite } from '../capacityOfferTypes' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const offer = { offerNo: 'CO/1', tripNo: 'TR-1', recordVersion: '2', tripRecordVersion: '3', capacityKnown: false, destinations: [{ destinationKey: 'FBA:X', name: 'X', deliveryIndex: '1', remaining: null }], segments: [{ fromIndex: '0', toIndex: '1', fromName: '我仓', toName: 'X', planned: null, reserved: { palletSpaces: '0', weightKg: '0', volumeCbm: '0' }, remaining: null }] } as unknown as CapacityOffer +const inquiry = { bookingNo: 'FB/1', offerNo: 'CO/1', recordVersion: '1', tripRecordVersion: '3', offerRecordVersion: '2', expectedPalletCount: null, agreedAmount: null, quoteSource: null, cargo: { packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: '0' } } as unknown as CapacityInquiry +const envelope = (data: T) => ({ code: '200', data }) +beforeEach(() => vi.clearAllMocks()) +it('keeps unknown quantities distinct from a known zero price and normalizes segment facts', () => { + expect(normalizeInquiry(inquiry)).toMatchObject({ recordVersion: 1, expectedPalletCount: null, agreedAmount: null, cargo: { packageCount: null, unitPrice: 0 } }) + expect(normalizeOffer(offer)).toMatchObject({ capacityKnown: false, destinations: [{ remaining: null }], segments: [{ planned: null, reserved: { palletSpaces: 0 }, remaining: null }] }) +}) +it('uses only the dedicated pool endpoint for sales reads and forwards the requested page once', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [offer], total: '1', page: '2', size: '20', pages: '2' })).mockResolvedValueOnce(envelope({ records: [inquiry], total: '1', page: '1', size: '20', pages: '1' })).mockResolvedValueOnce(envelope([{ id: '9007199254740993', code: 'C-1', name: '测试客户' }])) + await listCapacityOffers({ page: 2, size: 20 }); await listCapacityInquiries({ scope: 'MINE' }); const customers = await listOfferCustomers('测试') + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers', { params: { page: 2, size: 20 } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/inquiries', { params: { scope: 'MINE' } }) + expect(customers[0]?.id).toBe('9007199254740993'); expect(http.get).toHaveBeenCalledTimes(3) +}) +it('submits one command with encoded business numbers and an unchanged retry key', async () => { + vi.mocked(http.post).mockResolvedValue(envelope({ offer, inquiry, replayed: false })) + const body = { customerId: '19', idempotencyKey: 'stable-test-key', cargo: { packageCount: null } } as InquiryWrite + await saveCapacityInquiry('CO/1', null, body) + expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/inquiries', body) + const command = { recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 3, idempotencyKey: 'confirm-stable-key' } + await commandCapacityInquiry('FB/1', 'confirm', command) + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/capacity-offers/inquiries/FB%2F1/confirm', command) +}) +it('reads both quote pages from the offer-scoped URL and preserves the exact source version', async () => { + const source = { quoteNo: 'FQ-POOL', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY', customerId: '19', customerName: '测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '35', currency: 'CNY', priceBasis: 'PALLET', quotedUnitPrice: '25.50', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' } + vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [source], page: '1', size: '20', total: '21', pages: '2' })).mockResolvedValueOnce(envelope({ records: [{ ...source, quoteNo: 'FQ-POOL-SECOND' }], page: '2', size: '20', total: '21', pages: '2' })) + const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY' as const, priceBasis: 'PALLET' as const, page: 1, size: 20 } + const first = await listOfferQuotes('CO/1', criteria) + const second = await listOfferQuotes('CO/1', { ...criteria, page: 2 }) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: criteria }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: { ...criteria, page: 2 } }) + expect(http.get).toHaveBeenCalledTimes(2) + expect(vi.mocked(http.get).mock.calls.every(([url]) => !url.includes('/fleet/trips/'))).toBe(true) + expect(first.records[0]).toMatchObject({ quoteNo: source.quoteNo, recordVersion: source.recordVersion, quotedUnitPrice: 25.5, distanceMiles: 35 }) + expect(second).toMatchObject({ page: 2, size: 20, total: 21, pages: 2, records: [{ quoteNo: 'FQ-POOL-SECOND', recordVersion: source.recordVersion }] }) +}) diff --git a/src/modules/overseas-master-data/api/capacityOffers.ts b/src/modules/overseas-master-data/api/capacityOffers.ts new file mode 100644 index 00000000..a70a955c --- /dev/null +++ b/src/modules/overseas-master-data/api/capacityOffers.ts @@ -0,0 +1,28 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes' +import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates' +import type { TripBookingQuoteCriteria } from './tripBookings' +const base = '/platform/v1/last-leg/fleet/capacity-offers' +const path = (value: string) => `${base}/${encodeURIComponent(value)}` +const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}` +const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n } +const nullable = (value: number | null) => value == null ? null : number(value) +const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) } +export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } } +export function normalizeInquiry(value: CapacityInquiry): CapacityInquiry { return { ...value, recordVersion: number(value.recordVersion), offerRecordVersion: number(value.offerRecordVersion), tripRecordVersion: number(value.tripRecordVersion), expectedPalletCount: nullable(value.expectedPalletCount), agreedAmount: nullable(value.agreedAmount), quoteSource: value.quoteSource ? normalizeFleetQuoteSource(value.quoteSource) : null, cargo: { ...value.cargo, packageCount: nullable(value.cargo.packageCount), palletSpaces: nullable(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } } } +function page(value: OfferPage, normalize: (row: T) => T): OfferPage { return { records: value.records.map(normalize), page: number(value.page), size: number(value.size), total: number(value.total), pages: number(value.pages) } } +const offerResult = (value: OfferResult): OfferResult => ({ ...value, offer: normalizeOffer(value.offer) }) +const inquiryResult = (value: InquiryResult): InquiryResult => ({ ...offerResult(value), inquiry: normalizeInquiry(value.inquiry) }) +async function get(url: string, params?: object) { return unwrapApiResponse(await http.get, ApiEnvelope>(url, { params })) } +async function post(url: string, body: object) { return unwrapApiResponse(await http.post, ApiEnvelope>(url, body)) } +async function put(url: string, body: object) { return unwrapApiResponse(await http.put, ApiEnvelope>(url, body)) } +export async function listCapacityOffers(query: OfferQuery) { return page(await get>(base, query), normalizeOffer) } +export async function getCapacityOffer(offerNo: string): Promise { const value = await get(path(offerNo)); return { offer: normalizeOffer(value.offer), inquiries: page(value.inquiries, normalizeInquiry) } } +export async function listCapacityInquiries(query: InquiryQuery) { return page(await get>(`${base}/inquiries`, query), normalizeInquiry) } +export async function getCapacityInquiry(bookingNo: string) { return normalizeInquiry(await get(inquiryPath(bookingNo))) } +export async function saveCapacityOffer(offerNo: string | null, body: OfferWrite) { return offerResult(await (offerNo ? put(path(offerNo), body) : post(base, body))) } +export async function commandCapacityOffer(offerNo: string, action: 'publish' | 'pause' | 'resume' | 'close', body: OfferCommand) { return offerResult(await post(`${path(offerNo)}/${action}`, body)) } +export async function saveCapacityInquiry(offerNo: string, bookingNo: string | null, body: InquiryWrite) { return inquiryResult(await (bookingNo ? put(inquiryPath(bookingNo), body) : post(`${path(offerNo)}/inquiries`, body))) } +export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post(`${inquiryPath(bookingNo)}/${action}`, body)) } +export const listOfferCustomers = (keyword?: string) => get(`${base}/customer-options`, { keyword }) +export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get(`${path(offerNo)}/quote-candidates`, query)) } diff --git a/src/modules/overseas-master-data/api/companyVehicleCosts.test.ts b/src/modules/overseas-master-data/api/companyVehicleCosts.test.ts new file mode 100644 index 00000000..65d24e72 --- /dev/null +++ b/src/modules/overseas-master-data/api/companyVehicleCosts.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http, ApiResponseError } from '@/shared/api/http' +import { getCompanyCost, commandCompanyCost, payCompanyCost, listCompanyCostHistory, listCompanyCostPayments, uploadCompanyCostVoucher, downloadCompanyCostVoucher } from './companyVehicleCosts' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn() } })) +const source = { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '90071992547409931', vehiclePlate: 'VEH-A', tripNo: null, + supplierId: '90071992547409932', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: '10.00', exchangeRate: '7.000000', exchangeRateMonth: '2026-01', amount: '70.00', remark: null } +const wire = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '90071992547409933', paidAmount: '0.00', outstandingAmount: '70.00', source } +const base = '/platform/finance/v1/vehicle-costs/VC-ONE' +const ok = (data: unknown) => ({ code: '200', data }) +beforeEach(() => vi.resetAllMocks()) +it('reads real company detail while preserving long versions and original-currency facts', async () => { + vi.mocked(http.get).mockResolvedValue(ok(wire)) + const result = await getCompanyCost('VC-ONE') + expect(result).toMatchObject({ recordVersion: wire.recordVersion, paidAmount: 0, outstandingAmount: 70, + source: { supplierId: source.supplierId, originalAmount: 10, amount: 70, exchangeRate: 7 } }) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base) +}) +it('sends exact action versions and stable Idempotency-Key headers for booking, void and payment', async () => { + vi.mocked(http.post).mockResolvedValue(ok(wire)) + const command = { expectedVersion: wire.recordVersion, reason: '核对' } + await commandCompanyCost('VC-ONE', 'approve', 'approve-key', command) + await commandCompanyCost('VC-ONE', 'void', 'void-key', command) + const payment = { expectedVersion: wire.recordVersion, amount: 20, paidAt: '2026-01-02T00:00:00Z', method: 'BANK_TRANSFER' as const, referenceNo: null, voucherFileIds: ['90071992547409934'], remark: null } + await payCompanyCost('VC-ONE', 'payment-key', payment) + expect(http.post).toHaveBeenNthCalledWith(1, `${base}/approve`, command, { headers: { 'Idempotency-Key': 'approve-key' } }) + expect(http.post).toHaveBeenNthCalledWith(2, `${base}/void`, command, { headers: { 'Idempotency-Key': 'void-key' } }) + expect(http.post).toHaveBeenNthCalledWith(3, `${base}/payments`, payment, { headers: { 'Idempotency-Key': 'payment-key' } }) +}) +it('loads separately paged history and payment records without treating unknown payable as zero', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [{ recordVersion: '0', action: 'RECEIVE', reason: null, operatorName: '系统', occurredAt: '2026-01-01T00:00:00Z', snapshot: { ...wire, status: 'PENDING', outstandingAmount: null } }], page: 2, size: 10, total: 11, pages: 2 })) + const history = await listCompanyCostHistory('VC-ONE', 2) + expect(history.records[0]?.snapshot.outstandingAmount).toBeNull() + vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [{ paymentNo: 'VP-ONE', currency: 'CNY', amount: '20.00', voucherFileIds: ['90071992547409934'] }], page: 1, size: 10, total: 1, pages: 1 })) + const payments = await listCompanyCostPayments('VC-ONE') + expect(payments.records[0]).toMatchObject({ amount: 20, voucherFileIds: ['90071992547409934'] }) + expect(http.get).toHaveBeenNthCalledWith(1, `${base}/history`, { params: { page: 2, size: 10 } }) + expect(http.get).toHaveBeenNthCalledWith(2, `${base}/payments`, { params: { page: 1, size: 10 } }) +}) +it('uploads only to the selected cost and rejects customer or operational photographs as payment evidence', async () => { + const file = new File(['png'], 'payment.png', { type: 'image/png' }) + vi.mocked(http.post).mockResolvedValueOnce(ok({ fileId: '90071992547409934', fileName: 'payment.png', contentType: 'image/png', sizeBytes: '3', ownerScope: 'PLATFORM', tenantId: '0', businessType: 'FINANCE_VEHICLE_VOUCHER' })) + expect(await uploadCompanyCostVoucher('VC-ONE', file)).toMatchObject({ fileId: '90071992547409934', sizeBytes: 3 }) + expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe(`${base}/vouchers`) + expect((vi.mocked(http.post).mock.calls[0]?.[1] as FormData).get('file')).toBe(file) + vi.mocked(http.post).mockResolvedValueOnce(ok({ fileId: '1', ownerScope: 'CUSTOMER', tenantId: '12', businessType: 'FINANCE_VOUCHER' })) + await expect(uploadCompanyCostVoucher('VC-ONE', file)).rejects.toThrow('凭证返回内容无效') +}) +it('keeps JSON download errors as diagnostic errors rather than successful images', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'FIN_404', message: '不存在', requestId: 'request-one', traceId: 'trace-one' })], { type: 'application/json' })) + await expect(downloadCompanyCostVoucher('VC-ONE', '11')).rejects.toBeInstanceOf(ApiResponseError) +}) +it('rejects mismatched costs and imprecise money instead of displaying successful facts', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok({ ...wire, costNo: 'VC-OTHER' })).mockResolvedValueOnce(ok({ ...wire, paidAmount: '90071992547409.92' })) + await expect(getCompanyCost('VC-ONE')).rejects.toThrow('归属') + await expect(getCompanyCost('VC-ONE')).rejects.toThrow('精确展示') +}) diff --git a/src/modules/overseas-master-data/api/companyVehicleCosts.ts b/src/modules/overseas-master-data/api/companyVehicleCosts.ts new file mode 100644 index 00000000..459da0f0 --- /dev/null +++ b/src/modules/overseas-master-data/api/companyVehicleCosts.ts @@ -0,0 +1,111 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export const companyCostPermissions = { + list: 'platform:finance:vehicle-cost:list', approve: 'platform:finance:vehicle-cost:approve', + void: 'platform:finance:vehicle-cost:void', pay: 'platform:finance:vehicle-cost:pay', upload: 'platform:finance:vehicle-cost:upload', +} as const +export type CompanyCostState = 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED' +export interface CompanyCost { + costNo: string; subjectCode: 'PLATFORM_DEFAULT'; supplierName: string; currency: 'CNY'; status: CompanyCostState + recordVersion: string; paidAmount: number; outstandingAmount: number | null + source: { sourceType: string; sourceNo: string; sourceVersion: string; vehicleId: string; vehiclePlate: string; tripNo: string | null; + supplierId: string; feeName: string; businessDate: string; originalCurrency: 'CNY' | 'USD'; originalAmount: number; + exchangeRate: number; exchangeRateMonth: string; amount: number; remark: string | null } +} +export interface CompanyCostCommand { expectedVersion: string; reason: string | null } +export interface CompanyCostPayment { + expectedVersion: string; amount: number; paidAt: string; method: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' + referenceNo: string | null; voucherFileIds: string[]; remark: string | null +} +export interface CompanyCostHistory { recordVersion: string; action: string; reason: string | null; operatorName: string; occurredAt: string; snapshot: CompanyCost } +export interface CompanyCostPaid { paymentNo: string; currency: 'CNY'; amount: number; paidAt: string; method: string; referenceNo: string | null; voucherFileIds: string[]; operatorName: string; recordedAt: string; remark: string | null } +export interface CompanyCostVoucher { fileId: string; fileName: string; contentType: string; sizeBytes: number } +type Numeric = string | number +type CostWire = Omit & { + recordVersion: Numeric; paidAmount: Numeric; outstandingAmount?: Numeric | null + source: Omit & { + sourceVersion: Numeric; supplierId: Numeric; originalAmount: Numeric; exchangeRate: Numeric; amount: Numeric } +} +const base = (no: string) => `/platform/finance/v1/vehicle-costs/${encodeURIComponent(no)}` +function version(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('公司成本版本或标识无效') + return String(value) +} +function money(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('公司成本金额无效') + const [whole, fraction = ''] = String(value).split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('公司成本金额超出精确展示范围') + return Number(value) +} +function integer(value: Numeric): number { + const result = Number(version(value)) + if (!Number.isSafeInteger(result)) throw new Error('公司成本分页数值无效') + return result +} +function cost(value: CostWire, expected?: string): CompanyCost { + if ((expected && value.costNo !== expected) || value.subjectCode !== 'PLATFORM_DEFAULT' || value.currency !== 'CNY' + || !['PENDING', 'APPROVED', 'PARTIALLY_PAID', 'PAID', 'VOIDED'].includes(value.status)) throw new Error('公司成本响应归属或状态无效') + const rate = Number(value.source.exchangeRate) + if (!Number.isFinite(rate) || rate <= 0 || !['CNY', 'USD'].includes(value.source.originalCurrency)) throw new Error('公司成本汇率或原币无效') + return { ...value, recordVersion: version(value.recordVersion), paidAmount: money(value.paidAmount), + outstandingAmount: value.outstandingAmount == null ? null : money(value.outstandingAmount), source: { ...value.source, + sourceVersion: version(value.source.sourceVersion), supplierId: version(value.source.supplierId), originalAmount: money(value.source.originalAmount), + exchangeRate: rate, amount: money(value.source.amount), tripNo: value.source.tripNo ?? null, remark: value.source.remark ?? null } } +} +type PageWire = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +function page(value: PageWire, convert: (value: T) => R): PageResult { + return { records: value.records.map(convert), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +export async function getCompanyCost(no: string): Promise { + return cost(unwrapApiResponse(await http.get, ApiEnvelope>(base(no))), no) +} +export async function commandCompanyCost(no: string, action: 'approve' | 'void', key: string, request: CompanyCostCommand): Promise { + return cost(unwrapApiResponse(await http.post, ApiEnvelope>(`${base(no)}/${action}`, request, { headers: { 'Idempotency-Key': key } })), no) +} +export async function payCompanyCost(no: string, key: string, request: CompanyCostPayment): Promise { + return cost(unwrapApiResponse(await http.post, ApiEnvelope>(`${base(no)}/payments`, request, { headers: { 'Idempotency-Key': key } })), no) +} +export async function listCompanyCostHistory(no: string, currentPage = 1): Promise> { + type HistoryWire = Omit & { recordVersion: Numeric; snapshot: CostWire } + const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(no)}/history`, { params: { page: currentPage, size: 10 } })) + return page(value, row => ({ ...row, recordVersion: version(row.recordVersion), snapshot: cost(row.snapshot, no) })) +} +export async function listCompanyCostPayments(no: string, currentPage = 1): Promise> { + type PaidWire = Omit & { amount: Numeric; voucherFileIds: Numeric[] } + const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(no)}/payments`, { params: { page: currentPage, size: 10 } })) + return page(value, row => { + if (row.currency !== 'CNY') throw new Error('公司付款币种无效') + return { ...row, amount: money(row.amount), voucherFileIds: row.voucherFileIds.map(version) } + }) +} +export async function uploadCompanyCostVoucher(no: string, file: File): Promise { + type Wire = { fileId: Numeric; fileName: string; contentType: string; sizeBytes: Numeric; ownerScope: string; tenantId: Numeric; businessType: string } + const form = new FormData(); form.append('file', file) + const value = unwrapApiResponse(await http.post, ApiEnvelope>(`${base(no)}/vouchers`, form)) + if (value.ownerScope !== 'PLATFORM' || String(value.tenantId) !== '0' || value.businessType !== 'FINANCE_VEHICLE_VOUCHER' + || !['image/jpeg', 'image/png'].includes(value.contentType) || integer(value.sizeBytes) <= 0) throw new Error('公司付款凭证返回内容无效') + return { fileId: version(value.fileId), fileName: value.fileName, contentType: value.contentType, sizeBytes: integer(value.sizeBytes) } +} +async function rejectFileError(value: unknown, status: number): Promise { + if (value instanceof Blob && value.type.includes('json')) { + try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('付款凭证返回了无法识别的错误') } + } + if (value && typeof value === 'object' && 'code' in value) { + const error = value as ApiEnvelope + throw new ApiResponseError(error.message || '付款凭证读取失败', error.code, status, error.data, error.requestId, error.traceId) + } +} +export async function downloadCompanyCostVoucher(no: string, fileId: string): Promise { + try { + const value = await http.get(`${base(no)}/vouchers/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' }) + await rejectFileError(value, 200) + if (!(value instanceof Blob) || !value.size || !['image/png', 'image/jpeg'].includes(value.type)) throw new Error('付款凭证为空或格式无效') + return value + } catch (cause) { + if (axios.isAxiosError(cause)) await rejectFileError(cause.response?.data, cause.response?.status || 0) + throw cause + } +} diff --git a/src/modules/overseas-master-data/api/drivers.test.ts b/src/modules/overseas-master-data/api/drivers.test.ts new file mode 100644 index 00000000..81292d19 --- /dev/null +++ b/src/modules/overseas-master-data/api/drivers.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError, http } from '@/shared/api/http' +import { changeDriverStatus, createDriver, downloadDriverLicense, getDriver, getDriverQualification, listDriverHistory, listDrivers, reviewDriver, submitDriver, updateDriver, uploadDriverLicense } from './drivers' +import type { DriverDraft } from '../driverTypes' + +vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } })) +const id = '9223372036854775701' +const fileId = '9223372036854775801' +const base = '/platform/v1/last-leg/fleet/drivers' +const details: DriverDraft = { name: '测试司机', phone: 'test-phone', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: null } +const record = { id, driverNo: 'DR-TEST', details, active: true, reviewStatus: 'APPROVED', licenseFileId: fileId, recordVersion: '2', updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-22' } } +const ok = (data: unknown) => ({ code: '200', message: '成功', data }) +beforeEach(() => vi.clearAllMocks()) + +describe('driver APIs', () => { + it('preserves long IDs and date-only values, uses one page request, and normalizes versions', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [record], page: '1', size: '20', total: '1', pages: '1', summary: { total: '1', owned: '1', dispatched: '0', expiringSoon: '1', expired: '0' } })).mockResolvedValueOnce(ok(record)) + const result = await listDrivers({ page: 1, size: 20, active: false }) + expect(result).toMatchObject({ page: 1, total: 1, records: [{ id, licenseFileId: fileId, recordVersion: 2, details }] }) + expect(http.get).toHaveBeenCalledTimes(1) + await getDriver(id) + expect(http.get).toHaveBeenNthCalledWith(1, base, { params: { page: 1, size: 20, active: false } }) + expect(http.get).toHaveBeenNthCalledWith(2, `${base}/${id}`, { params: { expectedEnd: undefined } }) + }) + + it('sends current versions to edits, reviews, status changes, and resubmission', async () => { + vi.mocked(http.post).mockResolvedValue(ok(record)) + vi.mocked(http.put).mockResolvedValue(ok(record)) + vi.mocked(http.patch).mockResolvedValue(ok(record)) + await createDriver(details) + await updateDriver(id, 2, details) + await reviewDriver(id, 3, 'REJECTED', '附件不清楚') + await changeDriverStatus(id, 4, false, '暂停驾驶') + await submitDriver(id, 5) + expect(http.post).toHaveBeenCalledWith(base, details) + expect(http.put).toHaveBeenCalledWith(`${base}/${id}`, { recordVersion: 2, details }) + expect(http.post).toHaveBeenCalledWith(`${base}/${id}/review`, { recordVersion: 3, decision: 'REJECTED', reason: '附件不清楚' }) + expect(http.patch).toHaveBeenCalledWith(`${base}/${id}/status`, { recordVersion: 4, active: false, reason: '暂停驾驶' }) + expect(http.post).toHaveBeenCalledWith(`${base}/${id}/submit`, { recordVersion: 5 }) + }) + + it('uploads a real multipart file with the version and leaves multipart boundary to the browser', async () => { + vi.mocked(http.post).mockResolvedValue(ok(record)) + const file = new File(['%PDF-1.7'], 'license.pdf', { type: 'application/pdf' }) + await uploadDriverLicense(id, 2, file) + const [url, body] = vi.mocked(http.post).mock.calls[0]! + expect(url).toBe(`${base}/${id}/license`) + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('recordVersion')).toBe('2') + expect((body as FormData).get('file')).toBe(file) + }) + + it('takes task qualification from server and keeps history snapshots distinct', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok(record.qualification)).mockResolvedValueOnce(ok({ page: '1', size: '20', total: '1', pages: '1', records: [{ recordVersion: '1', action: 'UPLOAD_LICENSE', snapshot: { ...record, recordVersion: '1', reviewStatus: 'PENDING' } }] })) + const expectedEnd = '2026-10-20T18:00:00Z' + expect(await getDriverQualification(id, expectedEnd)).toEqual(record.qualification) + expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/qualification`, { params: { expectedEnd } }) + expect((await listDriverHistory(id)).records[0]).toMatchObject({ recordVersion: 1, snapshot: { recordVersion: 1, reviewStatus: 'PENDING' } }) + }) + + it('downloads only real supported blobs and rejects HTTP200 JSON business failures', async () => { + const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' }) + vi.mocked(http.get).mockResolvedValueOnce(pdf).mockResolvedValueOnce({ code: 'A0404', message: '文件未关联', requestId: 'test-request' }).mockResolvedValueOnce(new Blob([''], { type: 'text/html' })) + expect(await downloadDriverLicense(id, fileId)).toBe(pdf) + expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/license/${fileId}`, { responseType: 'blob' }) + await expect(downloadDriverLicense(id, fileId)).rejects.toMatchObject({ message: '文件未关联', requestId: 'test-request' }) + await expect(downloadDriverLicense(id, fileId)).rejects.toThrow('格式不受支持') + }) + + it('does not turn a failed write into a success or round an unsafe record version', async () => { + vi.mocked(http.post).mockResolvedValueOnce({ code: 'A0409', message: '请刷新后重试' }).mockResolvedValueOnce(ok({ ...record, recordVersion: '9223372036854775807' })) + await expect(reviewDriver(id, 1, 'APPROVED', null)).rejects.toBeInstanceOf(ApiResponseError) + await expect(createDriver(details)).rejects.toThrow('无效的分页或版本信息') + }) +}) diff --git a/src/modules/overseas-master-data/api/drivers.ts b/src/modules/overseas-master-data/api/drivers.ts new file mode 100644 index 00000000..1e9028d3 --- /dev/null +++ b/src/modules/overseas-master-data/api/drivers.ts @@ -0,0 +1,100 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { DriverDetail, DriverDraft, DriverHistory, DriverQualification, DriverReviewStatus, DriverLicenseState, DriverSummary, DriverWarehouseOption } from '../driverTypes' +import type { PageResult } from './overseasMasterData' + +const base = '/platform/v1/last-leg/fleet/drivers' +type Numeric = string | number +type DriverWire = Omit & { recordVersion: Numeric } +type HistoryWire = Omit & { recordVersion: Numeric; snapshot: DriverWire } +type PageWire = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } + +export interface DriverQuery { + page: number + size: number + keyword?: string + active?: boolean + reviewStatus?: DriverReviewStatus + ownership?: DriverDraft['ownership'] + region?: DriverDraft['region'] + licenseState?: DriverLicenseState + expectedEnd?: string +} + +function numeric(value: Numeric): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) throw new Error('司机接口返回了无效的分页或版本信息') + return result +} +const driver = (value: DriverWire): DriverDetail => ({ ...value, recordVersion: numeric(value.recordVersion) }) +function page(value: PageWire, normalize: (record: W) => T): PageResult { + return { records: value.records.map(normalize), page: numeric(value.page), size: numeric(value.size), total: numeric(value.total), pages: numeric(value.pages) } +} +const path = (id: string) => `${base}/${encodeURIComponent(id)}` + +type DriverPageWire = PageWire & { summary: { [K in keyof DriverSummary]: Numeric } } +export async function listDrivers(query: DriverQuery): Promise & { summary: DriverSummary }> { + const result = unwrapApiResponse(await http.get, ApiEnvelope>(base, { params: query })) + return { ...page(result, driver), summary: { total: numeric(result.summary.total), owned: numeric(result.summary.owned), dispatched: numeric(result.summary.dispatched), expiringSoon: numeric(result.summary.expiringSoon), expired: numeric(result.summary.expired) } } +} +export async function listDriverWarehouses(region: DriverDraft['region']): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/warehouse-options`, { params: { region } })) +} +export async function getDriver(id: string, expectedEnd?: string): Promise { + return driver(unwrapApiResponse(await http.get, ApiEnvelope>(path(id), { params: { expectedEnd } }))) +} +export async function getDriverQualification(id: string, expectedEnd: string): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${path(id)}/qualification`, { params: { expectedEnd } })) +} +export async function listDriverHistory(id: string, currentPage = 1, size = 20): Promise> { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${path(id)}/history`, { params: { page: currentPage, size } })) + return page(result, value => ({ ...value, recordVersion: numeric(value.recordVersion), snapshot: driver(value.snapshot) })) +} +export async function createDriver(details: DriverDraft): Promise { + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(base, details))) +} +export async function updateDriver(id: string, recordVersion: number, details: DriverDraft): Promise { + return driver(unwrapApiResponse(await http.put, ApiEnvelope>(path(id), { recordVersion, details }))) +} +export async function changeDriverStatus(id: string, recordVersion: number, active: boolean, reason: string): Promise { + return driver(unwrapApiResponse(await http.patch, ApiEnvelope>(`${path(id)}/status`, { recordVersion, active, reason }))) +} +export async function reviewDriver(id: string, recordVersion: number, decision: 'APPROVED' | 'REJECTED', reason: string | null): Promise { + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(id)}/review`, { recordVersion, decision, reason }))) +} +export async function submitDriver(id: string, recordVersion: number): Promise { + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(id)}/submit`, { recordVersion }))) +} +export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise { + const form = new FormData() + form.append('recordVersion', String(recordVersion)) + form.append('file', file) + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(id)}/license`, form))) +} + +async function checkDownloadError(payload: unknown, status: number): Promise { + let value = payload + if (payload instanceof Blob) { + if (!payload.type.includes('json')) return + try { value = JSON.parse(await payload.text()) as unknown } + catch { throw new ApiResponseError('驾驶证接口返回了无法识别的错误响应', 'INVALID_DOWNLOAD_RESPONSE', status) } + } + if (typeof value === 'object' && value !== null && 'code' in value) { + const error = value as Partial> + throw new ApiResponseError(error.message || '驾驶证读取失败', error.code || 'DOWNLOAD_FAILED', status, error.data, error.requestId, error.traceId) + } +} + +export async function downloadDriverLicense(id: string, fileId: string): Promise { + try { + const blob = await http.get(`${path(id)}/license/${encodeURIComponent(fileId)}`, { responseType: 'blob' }) + await checkDownloadError(blob, 200) + if (!(blob instanceof Blob) || blob.size === 0 || !['application/pdf', 'image/jpeg', 'image/png'].includes(blob.type)) { + throw new Error('驾驶证文件为空或格式不受支持') + } + return blob + } catch (error) { + if (axios.isAxiosError(error)) await checkDownloadError(error.response?.data, error.response?.status || 0) + throw error + } +} diff --git a/src/modules/overseas-master-data/api/fleetQuoteCandidates.test.ts b/src/modules/overseas-master-data/api/fleetQuoteCandidates.test.ts new file mode 100644 index 00000000..4b28c285 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuoteCandidates.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listFleetSaleQuoteCandidates, fleetQuoteReference, type FleetSaleQuoteCriteria } from './fleetQuoteCandidates' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn() } })) +const criteria: FleetSaleQuoteCriteria = { customerId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', effectiveOn: '2026-10-03', priceBasis: 'PALLET', page: 1, size: 20 } +const snapshot = { quoteNo: 'FQ-SOURCE', recordVersion: '90071992547409932', priceItem: 'LOOSE_DELIVERY', customerId: criteria.customerId, customerName: '委托客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '10', currency: 'USD', priceBasis: 'PALLET', quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: criteria.effectiveOn } +beforeEach(() => vi.clearAllMocks()) +it('keeps source identity/version and explicit zero, without constructing a booking write or total', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [snapshot], page: '1', size: '20', total: '1', pages: '1' } }) + const result = await listFleetSaleQuoteCandidates(criteria) + expect(result.records[0]).toMatchObject({ recordVersion: snapshot.recordVersion, customerId: criteria.customerId, quotedUnitPrice: 0, distanceMiles: 10 }) + expect(fleetQuoteReference(result.records[0]!)).toEqual({ quoteNo: snapshot.quoteNo, recordVersion: snapshot.recordVersion, priceItem: 'LOOSE_DELIVERY' }) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria }) + expect(result.records[0]).not.toHaveProperty('agreedAmount') +}) +it('does not turn an unknown price into an eligible zero-price candidate', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...snapshot, quotedUnitPrice: null }], page: 1, size: 20, total: 1, pages: 1 } }) + await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toThrow('金额格式无效') +}) +it('surfaces permission errors instead of returning an empty successful list', async () => { + vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无报价读取权限', requestId: 'candidate-test-request' }) + await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toMatchObject({ code: 'A0403', requestId: 'candidate-test-request' }) +}) diff --git a/src/modules/overseas-master-data/api/fleetQuoteCandidates.ts b/src/modules/overseas-master-data/api/fleetQuoteCandidates.ts new file mode 100644 index 00000000..a9f3f429 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuoteCandidates.ts @@ -0,0 +1,30 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' +import { fleetMoney, fleetNumber, fleetPage, fleetVersion } from './fleetQuotes' + +export interface FleetSaleQuoteCriteria { + customerId: string; originCity: string; destinationCode: string; currency: 'USD' | 'CNY' + effectiveOn: string; priceBasis: 'ORDER' | 'PALLET'; page?: number; size?: number +} +export interface FleetQuoteReference { quoteNo: string; recordVersion: string; priceItem: 'FULL_DELIVERY' | 'LOOSE_DELIVERY' } +export interface FleetQuoteSourceSnapshot extends FleetQuoteReference { + customerId: string; customerName: string; originCity: string; destinationCode: string; distanceMiles: number + currency: 'USD' | 'CNY'; priceBasis: 'ORDER' | 'PALLET'; quotedUnitPrice: number + validFrom: string; validTo: string; effectiveOn: string +} +type Numeric = string | number +export type WireSnapshot = Omit & { + recordVersion: Numeric; distanceMiles: Numeric; quotedUnitPrice: Numeric +} +export type WireQuotePage = Omit, 'page' | 'size' | 'total' | 'pages'> & { page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +export const normalizeFleetQuoteSource = (row: WireSnapshot): FleetQuoteSourceSnapshot => ({ ...row, recordVersion: fleetVersion(row.recordVersion), distanceMiles: fleetNumber(row.distanceMiles), quotedUnitPrice: fleetMoney(row.quotedUnitPrice) }) +export const normalizeFleetQuotePage = (value: WireQuotePage): PageResult => fleetPage({ ...value, records: value.records.map(normalizeFleetQuoteSource) }) + +/** Only a candidate read: the booking writer must revalidate the reference and freeze its own source snapshot. */ +export async function listFleetSaleQuoteCandidates(criteria: FleetSaleQuoteCriteria): Promise> { + const response = unwrapApiResponse(await http.get, ApiEnvelope>('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria })) + return normalizeFleetQuotePage(response) +} +export function fleetQuoteReference(source: FleetQuoteSourceSnapshot): FleetQuoteReference { + return { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem } +} diff --git a/src/modules/overseas-master-data/api/fleetQuotes.test.ts b/src/modules/overseas-master-data/api/fleetQuotes.test.ts new file mode 100644 index 00000000..2b366b3a --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuotes.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http, ApiResponseError } from '@/shared/api/http' +import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const details: FleetQuoteDraft = { kind: 'SALE', partyId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', distanceMiles: 10, selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 2, validFrom: '2026-01-01', validTo: '2026-12-31', remark: null } +const quote = { quoteNo: 'FQ-ONE', details: { ...details, selfPickupPrice: '0', looseDeliveryPrice: '2', distanceMiles: '10' }, partyName: '委托企业', status: 'ACTIVE', recordVersion: '90071992547409931', updatedAt: '2026-10-01T00:00:00Z' } +const ok = (data: unknown) => ({ code: '200', data }) +beforeEach(() => vi.clearAllMocks()) +it('preserves long IDs and versions, explicit zero and absent prices', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ records: [quote], total: '1', size: '20', page: '1', pages: '1' })) + const result = await listFleetQuotes({ kind: 'SALE' }) + expect(result.records[0]).toMatchObject({ details, recordVersion: '90071992547409931' }) + expect(result.total).toBe(1) + vi.mocked(http.put).mockResolvedValue(ok(quote)); await updateFleetQuote(result.records[0]!, details) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/quotes/FQ-ONE', { recordVersion: '90071992547409931', details }) +}) +it('sends stable creation and batch keys without converting business references', async () => { + vi.mocked(http.post).mockResolvedValue(ok(quote)); await createFleetQuote('stable-key', details) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/quotes', { idempotencyKey: 'stable-key', details }) + vi.mocked(http.post).mockResolvedValue(ok({ importedRows: 1 })); await importFleetQuotes('batch-key', [details]) + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/quotes/import', { idempotencyKey: 'batch-key', rows: [details] }) +}) +it('does not download a business error JSON as a CSV file', async () => { + vi.mocked(http.get).mockResolvedValue(new TextEncoder().encode('{"code":"CONFLICT","message":"缩小范围","requestId":"r1"}').buffer) + await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '缩小范围', requestId: 'r1' }) +}) +it('preserves diagnostics from a non-200 export response', async () => { + vi.mocked(http.get).mockRejectedValue({ isAxiosError: true, response: { status: 403, data: new TextEncoder().encode('{"code":"403","message":"无导出权限","traceId":"t1"}').buffer } }) + await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '无导出权限', status: 403, traceId: 't1' }) +}) +it('rejects lossy money and numeric versions instead of rounding them', () => { + expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示') + expect(() => fleetVersion(9007199254740992)).toThrow('版本无效') + expect(fleetVersion('90071992547409931')).toBe('90071992547409931') +}) +it('recognizes deployed A04xx business rejection without treating S0500 or network failure as a definite abort', () => { + for (const code of ['A0400', 'A0409', 'A0422']) expect(isFleetCommandRejected(new ApiResponseError('业务拒绝', code, 200))).toBe(true) + expect(isFleetCommandRejected(new ApiResponseError('服务器结果不明', 'S0500', 200))).toBe(false) + expect(isFleetCommandRejected(new Error('Network error'))).toBe(false) +}) diff --git a/src/modules/overseas-master-data/api/fleetQuotes.ts b/src/modules/overseas-master-data/api/fleetQuotes.ts new file mode 100644 index 00000000..08db06e8 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuotes.ts @@ -0,0 +1,71 @@ +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import axios from 'axios' +import type { PageResult } from './overseasMasterData' + +export type FleetQuoteKind = 'PURCHASE' | 'SALE' +export interface FleetQuoteDraft { + kind: FleetQuoteKind; partyId: string; originCity: string; destinationCode: string; distanceMiles: number + currency: 'USD' | 'CNY'; selfPickupPrice: number | null; fullDeliveryPrice: number | null; looseDeliveryPrice: number | null + validFrom: string; validTo: string; remark: string | null +} +export interface FleetQuote { quoteNo: string; details: FleetQuoteDraft; partyName: string; status: 'ACTIVE' | 'INACTIVE' | 'DELETED'; recordVersion: string; updatedAt: string } +export interface FleetQuoteQuery { page?: number; size?: number; keyword?: string; kind?: FleetQuoteKind; status?: string; effectiveOn?: string } +type Numeric = string | number +type WireQuote = Omit & { recordVersion: Numeric; details: Omit & { distanceMiles: Numeric; selfPickupPrice: Numeric | null; fullDeliveryPrice: Numeric | null; looseDeliveryPrice: Numeric | null } } +export function fleetNumber(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || String(value).trim() === '' || !Number.isFinite(Number(value)) || Number(value) < 0) throw new Error('接口返回了无效数值') + return Number(value) +} +export function fleetMoney(value: Numeric): number { + const text = String(value) + if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error('金额格式无效') + const [whole, fraction = ''] = text.split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('金额超出精确展示范围') + return Number(value) +} +export function fleetVersion(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('接口版本无效') + return String(value) +} +export function isFleetCommandRejected(error: unknown): boolean { + return error instanceof ApiResponseError && ((error.status != null && error.status >= 400 && error.status < 500) + || /^A04\d\d$/.test(error.code) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(error.code)) +} +export function fleetPage(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult { + const integer = (n: Numeric) => { const result = fleetNumber(n); if (!Number.isSafeInteger(result)) throw new Error('接口分页超出有效范围'); return result } + return { ...value, page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +const base = '/platform/v1/last-leg/fleet/quotes' +const path = (no: string) => `${base}/${encodeURIComponent(no)}` +const quote = (v: WireQuote): FleetQuote => ({ ...v, recordVersion: fleetVersion(v.recordVersion), details: { ...v.details, partyId: String(v.details.partyId), distanceMiles: fleetNumber(v.details.distanceMiles), + selfPickupPrice: v.details.selfPickupPrice == null ? null : fleetNumber(v.details.selfPickupPrice), fullDeliveryPrice: v.details.fullDeliveryPrice == null ? null : fleetNumber(v.details.fullDeliveryPrice), looseDeliveryPrice: v.details.looseDeliveryPrice == null ? null : fleetNumber(v.details.looseDeliveryPrice) } }) +export async function listFleetQuotes(query: FleetQuoteQuery) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(base, { params: query })) + return fleetPage({ ...result, records: result.records.map(quote) }) +} +export async function createFleetQuote(idempotencyKey: string, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.post, ApiEnvelope>(base, { idempotencyKey, details }))) } +export async function updateFleetQuote(value: FleetQuote, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.put, ApiEnvelope>(path(value.quoteNo), { recordVersion: value.recordVersion, details }))) } +export async function setFleetQuoteStatus(value: FleetQuote, status: string) { return quote(unwrapApiResponse(await http.patch, ApiEnvelope>(`${path(value.quoteNo)}/status`, { recordVersion: value.recordVersion, status }))) } +export async function deleteFleetQuote(value: FleetQuote) { return quote(unwrapApiResponse(await http.delete, ApiEnvelope>(path(value.quoteNo), { params: { recordVersion: value.recordVersion } }))) } +export async function importFleetQuotes(idempotencyKey: string, rows: FleetQuoteDraft[]) { return unwrapApiResponse(await http.post, ApiEnvelope<{ importedRows: number }>>(`${base}/import`, { idempotencyKey, rows })) } +export async function fleetQuoteCustomers(keyword: string) { return unwrapApiResponse(await http.get, ApiEnvelope<{ id: string; name: string; code: string }[]>>(`${base}/customer-options`, { params: { keyword } })) } +export async function fleetQuoteSuppliers(keyword: string) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>('/platform/v1/suppliers', { params: { page: 1, size: 30, keyword, status: 'ACTIVE', category: 'TRUCKING', truckingScope: 'OVERSEAS' } })) + return result.records.map(row => ({ ...row, id: String(row.id) })) +} +export async function fleetCsv(path: string, params: object): Promise { + function check(bytes: ArrayBuffer, status: number) { + const text = new TextDecoder().decode(bytes) + if (!/^\s*[{[]/.test(text)) return + let error: ApiEnvelope + try { error = JSON.parse(text) as ApiEnvelope } catch { throw new ApiResponseError('导出接口返回了无效响应', 'INVALID_EXPORT_RESPONSE', status) } + throw new ApiResponseError(error.message || '导出失败', error.code || 'EXPORT_FAILED', status, error.data, error.requestId, error.traceId) + } + try { + const bytes = await http.get(path, { params, responseType: 'arraybuffer' }); check(bytes, 200) + return new Blob([bytes], { type: 'text/csv;charset=utf-8' }) + } catch (e) { if (axios.isAxiosError(e) && Object.prototype.toString.call(e.response?.data) === '[object ArrayBuffer]') check(e.response!.data as ArrayBuffer, e.response!.status); throw e } +} +export const exportFleetQuotes = (query: FleetQuoteQuery) => fleetCsv(`${base}/export`, query) +export function saveFleetCsv(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } diff --git a/src/modules/overseas-master-data/api/overseasMasterData.test.ts b/src/modules/overseas-master-data/api/overseasMasterData.test.ts index c69b9cb8..53dad4ad 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.test.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.test.ts @@ -23,7 +23,7 @@ const quoteDetail = { createdBy: 'admin', createdAt: '2026-08-24T09:00:00', } const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null } -const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' } +const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' } beforeEach(() => vi.clearAllMocks()) @@ -106,7 +106,7 @@ describe('overseas master data API', () => { expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 }) expect(detail.id).toBe(longId) - const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null } + const editable = { plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null } vi.mocked(http.post).mockResolvedValue(ok(vehicle)) vi.mocked(http.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' })) vi.mocked(http.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' })) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.ts b/src/modules/overseas-master-data/api/overseasMasterData.ts index 739a8bba..1046c3b2 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.ts @@ -17,7 +17,7 @@ type QuoteDetailWire = Omit & { usdToCnyAverage: Numeric; recordVersion: Numeric } type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null } type ExchangeRateSummaryWire = Omit & { latestUsdToCnyAverage: Numeric | null } -type VehicleWire = Omit & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric } +type VehicleWire = Omit & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null } function page(value: PagePayload, normalize: (record: W) => T): PageResult { return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } @@ -32,7 +32,7 @@ const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unit const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) }) const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) }) const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) }) -const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) }) +const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) }) type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string diff --git a/src/modules/overseas-master-data/api/tripBookings.test.ts b/src/modules/overseas-master-data/api/tripBookings.test.ts new file mode 100644 index 00000000..6dfd312a --- /dev/null +++ b/src/modules/overseas-master-data/api/tripBookings.test.ts @@ -0,0 +1,22 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripBookingQuoteCandidates, normalizeBooking, type WireBooking } from './tripBookings' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn() } })) +const source = { quoteNo: 'FQ-1', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY' as const, customerId: '9', customerName: '客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '30', currency: 'USD' as const, priceBasis: 'PALLET' as const, quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' } +beforeEach(() => vi.clearAllMocks()) +it('uses trip-scoped candidate criteria without accepting browser-computed city or date', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [source], page: '1', size: '20', total: '1', pages: '1' } }) + const criteria = { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD' as const, priceBasis: 'PALLET' as const } + const value = await listTripBookingQuoteCandidates('TR-1', criteria) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-1/bookings/quote-candidates', { params: criteria }) + expect(value.records[0]).toMatchObject({ recordVersion: source.recordVersion, quotedUnitPrice: 0 }) +}) +it('normalizes quote source without overwriting negotiated price and handles manual absence', () => { + const row: WireBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source, + cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: '1', palletSpaces: '2', weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: '12.50', currency: 'USD', contactName: null, contactPhone: null, remark: null } } + const value = normalizeBooking(row) + expect(value.quoteSource).toMatchObject({ quotedUnitPrice: 0, recordVersion: source.recordVersion }) + expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25) + expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull() + expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } }) +}) diff --git a/src/modules/overseas-master-data/api/tripBookings.ts b/src/modules/overseas-master-data/api/tripBookings.ts new file mode 100644 index 00000000..43384cf3 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripBookings.ts @@ -0,0 +1,52 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { normalizeFleetQuoteSource, normalizeFleetQuotePage, type WireSnapshot, type WireQuotePage } from './fleetQuoteCandidates' +import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCreate, TripBookingUpdate, TripBookingCommand, TripBookingResult, TripBookingList, TripIncomingEvidence, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind, TripAcceptedReceipt } from '../bookingTypes' +const base = '/platform/v1/last-leg/fleet/trips' +const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings` +type N = number | string +type WireCargo = Omit & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null } +export type WireBooking = Omit & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null } +type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] } +type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] } +type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean } +const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result } +const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result } +const nullable = (value: N | null) => value == null ? null : number(value) +const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) }) +export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, + packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } }) +const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true }) +export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number } +export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) { + return normalizeFleetQuotePage(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/quote-candidates`, { params: criteria }))) +} +export async function listFleetCustomers(keyword?: string): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/customer-options`, { params: { keyword } })) +} +export async function listTripBookings(tripNo: string): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>>(path(tripNo))) + return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) } +} +export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise { + return result(unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), body))) +} +export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise { + return result(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body))) +} +async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise { + return result(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body))) +} +export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body) +export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body) + +const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}` +export async function getTripIncoming(tripNo: string, bookingNo: string): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`)) + return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) } +} +async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise { + return result(unwrapApiResponse(await http.post, ApiEnvelope>(`${bookingPath(tripNo, bookingNo)}/${action}`, body))) +} +export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body) +export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body) +export const bindTripBookingDispatch = (tripNo: string, bookingNo: string, body: TripBookingBind) => incomingCommand(tripNo, bookingNo, 'dispatch', body) diff --git a/src/modules/overseas-master-data/api/tripCosts.test.ts b/src/modules/overseas-master-data/api/tripCosts.test.ts new file mode 100644 index 00000000..ab1d2976 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripCosts.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripCosts, createTripCost, updateTripCost, listTripCostHistory } from './tripCosts' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const details = { category: 'FUEL' as const, currency: 'USD' as const, amount: 12.5, incurredAt: '2026-01-01T00:00:00Z', remark: null } +const wire = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { ...details, amount: '12.50' }, recordVersion: '90071992547409931', updatedAt: details.incurredAt } +const summary = { totals: [{ currency: 'USD', directCost: '12.50', allocations: [] }], allocationBlockReason: '尚无实际发车装载记录' } +const ok = (data: unknown) => ({ code: '200', data }) +beforeEach(() => vi.clearAllMocks()) +it('normalizes numbers without turning missing allocation into zero or rounding long versions', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [wire], page: '1', size: '10', total: '1', pages: '1' }, summary })) + const result = await listTripCosts('TR-ONE') + expect(result.page.records[0]).toMatchObject({ details, recordVersion: wire.recordVersion }) + expect(result.summary).toMatchObject({ departureVersion: null, manifestHash: null, totals: [{ directCost: 12.5, ownCost: null, externalCost: null }] }) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs', { params: { page: 1, size: 10 } }) +}) +it('preserves explicit zero and keeps currencies in separate server totals', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [ + { currency: 'CNY', directCost: '0.00', ownCost: '0.00', externalCost: '0.00', allocations: [] }, + { currency: 'USD', directCost: '2.00', allocations: [] }] } })) + const result = await listTripCosts('TR-ONE') + expect(result.summary.totals.map(r => [r.currency, r.directCost])).toEqual([['CNY', 0], ['USD', 2]]) + expect(result.summary.totals[0]?.ownCost).toBe(0) +}) +it('preserves creation key, exact correction version and source-specific history path', async () => { + vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire)) + vi.mocked(http.get).mockResolvedValue(ok({ records: [{ recordVersion: wire.recordVersion, action: 'UPDATE', reason: '核对', occurredAt: details.incurredAt, snapshot: { cost: wire, summary } }], page: 1, size: 1, total: 1, pages: 1 })) + await createTripCost('TR-ONE', 'fixed-key', details) + await updateTripCost('TR-ONE', wire, details) + const history = await listTripCostHistory('TR-ONE', 'TC-ONE', 1, 1) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs', { idempotencyKey: 'fixed-key', details }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs/TC-ONE', { recordVersion: wire.recordVersion, details, reason: '更正本趟直接成本' }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs/TC-ONE/history', { params: { page: 1, size: 1 } }) + expect(history.records[0]?.snapshot.cost.recordVersion).toBe(wire.recordVersion) +}) +it('rejects invalid amounts instead of reporting successful zero costs', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [{ currency: 'USD', directCost: null, allocations: [] }] } })) + await expect(listTripCosts('TR-ONE')).rejects.toThrow('无效金额') +}) +it.each([' ', true, '0.001', 'NaN', '90071992547409.92'])('rejects malformed or imprecise money %s', async amount => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [{ currency: 'USD', directCost: amount, allocations: [] }] } })) + await expect(listTripCosts('TR-ONE')).rejects.toThrow(/无效金额|精确展示范围/) +}) diff --git a/src/modules/overseas-master-data/api/tripCosts.ts b/src/modules/overseas-master-data/api/tripCosts.ts new file mode 100644 index 00000000..d388e3ac --- /dev/null +++ b/src/modules/overseas-master-data/api/tripCosts.ts @@ -0,0 +1,64 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export type CostCategory = 'FUEL' | 'DRIVER' | 'TOLL' | 'PARKING' | 'OTHER' +export type CostCurrency = 'CNY' | 'USD' +export interface TripCostDraft { category: CostCategory; currency: CostCurrency; amount: number; incurredAt: string; remark: string | null } +export interface TripCost { costNo: string; tripNo: string; details: TripCostDraft; recordVersion: string; updatedAt: string } +export interface CostAllocation { businessNo: string; sourceType: 'OWN' | 'EXTERNAL'; dispatchNo: string; palletSpaces: number; amount: number } +export interface CostTotal { currency: CostCurrency; directCost: number; allocations: CostAllocation[]; ownCost: number | null; externalCost: number | null } +export interface CostSummary { departureVersion: string | null; manifestHash: string | null; allocationMethod: string | null; allocationBlockReason: string | null; totals: CostTotal[] } +export interface TripCosts { page: PageResult; summary: CostSummary } +export interface TripCostHistory { recordVersion: string; action: string; reason: string; occurredAt: string; snapshot: { cost: TripCost; summary: CostSummary } } +type Numeric = string | number +type CostWire = Omit & { recordVersion: Numeric; details: Omit & { amount: Numeric; remark?: string | null } } +type SummaryWire = Omit & { departureVersion?: Numeric | null; totals: Array & { + directCost: Numeric; ownCost?: Numeric | null; externalCost?: Numeric | null; allocations: Array & { amount: Numeric; palletSpaces: Numeric }> +}> } +type PageWire = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +const path = (tripNo: string) => `/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}/costs` +function decimal(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('车次成本接口返回了无效金额') + const [whole, fraction = ''] = String(value).split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('车次成本金额超出精确展示范围') + return Number(value) +} +function integer(value: Numeric): number { + const number = Number(value) + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) throw new Error('车次成本接口返回了无效分页或托位') + if (!Number.isSafeInteger(number)) throw new Error('车次成本接口返回了无效分页或托位') + return number +} +function version(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('车次成本版本无效') + return String(value) +} +function cost(value: CostWire): TripCost { return { ...value, recordVersion: version(value.recordVersion), details: { ...value.details, amount: decimal(value.details.amount), remark: value.details.remark ?? null } } } +function summary(value: SummaryWire): CostSummary { + return { departureVersion: value.departureVersion == null ? null : version(value.departureVersion), manifestHash: value.manifestHash ?? null, + allocationMethod: value.allocationMethod ?? null, allocationBlockReason: value.allocationBlockReason ?? null, + totals: value.totals.map(row => ({ ...row, directCost: decimal(row.directCost), ownCost: row.ownCost == null ? null : decimal(row.ownCost), + externalCost: row.externalCost == null ? null : decimal(row.externalCost), + allocations: row.allocations.map(part => ({ ...part, amount: decimal(part.amount), palletSpaces: integer(part.palletSpaces) })) })) } +} +function page(value: PageWire, transform: (row: T) => R): PageResult { + return { records: value.records.map(transform), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +export async function listTripCosts(tripNo: string, currentPage = 1, size = 10): Promise { + type Wire = { page: PageWire; summary: SummaryWire } + const result = unwrapApiResponse(await http.get, ApiEnvelope>(path(tripNo), { params: { page: currentPage, size } })) + return { page: page(result.page, cost), summary: summary(result.summary) } +} +export async function createTripCost(tripNo: string, idempotencyKey: string, details: TripCostDraft): Promise { + return cost(unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), { idempotencyKey, details }))) +} +export async function updateTripCost(tripNo: string, record: Pick, details: TripCostDraft, reason?: string): Promise { + return cost(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(record.costNo)}`, { recordVersion: record.recordVersion, details, reason: reason?.trim() || '更正本趟直接成本' }))) +} +export async function listTripCostHistory(tripNo: string, costNo: string, currentPage = 1, size = 10): Promise> { + type HistoryWire = Omit & { recordVersion: Numeric; snapshot: { cost: CostWire; summary: SummaryWire } } + type Wire = PageWire + const result = unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(costNo)}/history`, { params: { page: currentPage, size } })) + return page(result, row => ({ ...row, recordVersion: version(row.recordVersion), snapshot: { cost: cost(row.snapshot.cost), summary: summary(row.snapshot.summary) } })) +} diff --git a/src/modules/overseas-master-data/api/tripHandovers.test.ts b/src/modules/overseas-master-data/api/tripHandovers.test.ts new file mode 100644 index 00000000..7344cabb --- /dev/null +++ b/src/modules/overseas-master-data/api/tripHandovers.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripHandovers, previewTripHandover, confirmTripHandover, releaseHandoverResource, handoverVersion } from './tripHandovers' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn() } })) +beforeEach(() => vi.clearAllMocks()) +it('encodes trip scope and normalizes pagination without per-row requests', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: '1', size: '10', total: '0', pages: '0' } }) + expect(await listTripHandovers('TR/ONE')).toEqual({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR%2FONE/handovers', { params: { page: 1, size: 10 } }) +}) +it('keeps preview separate from the exact immutable confirmation payload', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} }) + const selection = { recordVersion: 7, vehicleId: '90071992547409931', driverId: '90071992547409932', handedOverAt: '2026-10-03T01:00:00Z', expectedDriverEndAt: '2026-10-04T01:00:00Z' } + await previewTripHandover('TR-ONE', selection) + const command = { selection, cargoHash: 'a'.repeat(64), location: '交接点', idempotencyKey: 'original-key' } + await confirmTripHandover('TR-ONE', command) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/trips/TR-ONE/handovers/preview', selection) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/trips/TR-ONE/handovers', command) +}) +it('ends only the selected old resource within its handover and trip', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} }) + const command = { recordVersion: 0, resourceKind: 'DRIVER' as const, actualEndAt: '2026-10-03T02:00:00Z', location: '司机任务结束点', idempotencyKey: 'end-original' } + await releaseHandoverResource('TR-ONE', 'HO/ONE', command) + expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-ONE/handovers/HO%2FONE/resources/release', command) +}) +it('refuses blank fractional or unsafe versions', () => { + for (const value of ['', ' ', '1.5', '90071992547409931']) expect(() => handoverVersion(value)).toThrow() +}) diff --git a/src/modules/overseas-master-data/api/tripHandovers.ts b/src/modules/overseas-master-data/api/tripHandovers.ts new file mode 100644 index 00000000..2a077403 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripHandovers.ts @@ -0,0 +1,44 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export interface HandoverSelection { recordVersion: number; vehicleId: string; driverId: string; expectedDriverEndAt: string; handedOverAt: string } +export interface HandoverCommand { selection: HandoverSelection; cargoHash: string; location: string; reason?: string; idempotencyKey: string } +export interface HandoverRelease { recordVersion: number; resourceKind: 'VEHICLE' | 'DRIVER'; actualEndAt: string; location: string; vehicleStatus?: string; idempotencyKey: string } +export interface HandoverResource { vehicleId: string; vehiclePlate: string | null; driverId: string; driverName: string | null; driverPhone: string | null } +type Numeric = number | string +export interface HandoverCargo { customerId: string; dispatchNo: string; itemId: string; palletId: string; destinationKey: string; packages: Numeric } +export interface HandoverDelivery { customerId: string; dispatchNo: string; deliveryNo: string; destinationKey: string; packages: Numeric; terminalAt: string | null } +export interface HandoverLoad { palletSpaces: Numeric; weightKg: Numeric; volumeCbm: Numeric } +export interface HandoverInspection { remainingCargo: HandoverCargo[]; completedDeliveries: HandoverDelivery[]; remainingPalletIds: string[]; occupied: HandoverLoad } +export interface HandoverPreview { tripNo: string; recordVersion: Numeric; cargoHash: string; previous: HandoverResource; replacement: HandoverResource; capacity: HandoverLoad; cargo: HandoverInspection; driverQualification: { canDispatch: boolean; expiringSoon: boolean; reason: string; licenseExpiresOn: string | null } } +export interface HandoverEnded { actualEndAt: string; location: string; vehicleStatus: string | null } +export interface HandoverRecord { + handoverNo: string; tripNo: string; recordVersion: Numeric + snapshot: { previous: HandoverResource; replacement: HandoverResource; handedOverAt: string; location: string; reason: string; expectedDriverEndAt: string; capacity: HandoverLoad; cargo: HandoverInspection; recordedAt: string } + vehicleRelease: HandoverEnded | null; driverRelease: HandoverEnded | null +} +const path = (tripNo: string) => `/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}/handovers` +export function handoverNumber(value: Numeric): number { + if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('交接接口缺少数值') + const result = Number(value) + if (!Number.isFinite(result) || result < 0) throw new Error('交接接口数值无效') + return result +} +export function handoverVersion(value: Numeric): number { + const result = handoverNumber(value) + if (!Number.isSafeInteger(result)) throw new Error('交接接口版本或数量无效') + return result +} +export async function listTripHandovers(tripNo: string, page = 1, size = 10): Promise> { + const result = unwrapApiResponse(await http.get, ApiEnvelope<{ records: HandoverRecord[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }>>(path(tripNo), { params: { page, size } })) + return { records: result.records, page: handoverVersion(result.page), size: handoverVersion(result.size), total: handoverVersion(result.total), pages: handoverVersion(result.pages) } +} +export async function previewTripHandover(tripNo: string, selection: HandoverSelection): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/preview`, selection)) +} +export async function confirmTripHandover(tripNo: string, command: HandoverCommand): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), command)) +} +export async function releaseHandoverResource(tripNo: string, handoverNo: string, command: HandoverRelease): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(handoverNo)}/resources/release`, command)) +} diff --git a/src/modules/overseas-master-data/api/tripOperations.test.ts b/src/modules/overseas-master-data/api/tripOperations.test.ts new file mode 100644 index 00000000..2f5d598f --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperations.test.ts @@ -0,0 +1,48 @@ +import { expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripOperations, getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, uploadTripOperationsEvidence, downloadTripOperationsEvidence } from './tripOperations' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) +it('uses one aggregate response, keeps currency groups and never invents finance/cost amounts', async () => { + const counts = { physicalPallets: '1', palletSpaces: '2', ownPackages: '11', externalPackages: '4', externalAgreedAmounts: [{ currency: 'USD', amount: '0' }, { currency: 'CNY', amount: '10.25' }] } + const totals = [{ currency: 'USD', directCost: '10.00', ownCost: null, externalCost: null }] + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'TR1', destinations: [{ key: 'FBA:ONT8', label: 'ONT8' }, { key: 'FBA:LAX9', label: 'LAX9' }], externalPalletRates: [], measured: { actualDistanceMiles: '0.0', actualFuelLitres: null, remark: null, evidenceFiles: [] }, financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { allocationBlockReason: '路线不同', totals } }], total: '20', page: '1', size: '1', pages: '20' }, summary: { ...counts, trips: '20', inTransit: '12', completed: '8', costs: { visible: true, unrecordedTrips: '1', unallocatedTrips: '1', totals } }, charts: { physicalPallets: [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-01', value: '1' }] }], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } }) + const query = { from: '2026-10-01T00:00:00Z', to: '2026-10-03T00:00:00Z', sourceType: 'EXTERNAL' as const } + const result = await listTripOperations(query) + expect(result.summary.trips).toBe(20); expect(result.page.records).toHaveLength(1) + expect(result.summary.externalAgreedAmounts).toEqual([{ currency: 'USD', amount: 0 }, { currency: 'CNY', amount: 10.25 }]) + expect(result.page.records[0]).toMatchObject({ financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { totals: [{ directCost: 10, ownCost: null, externalCost: null }] } }) + expect(result.summary.costs).toMatchObject({ unrecordedTrips: 1, unallocatedTrips: 1 }) + expect(result.page.records[0]?.measured).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] }) + expect(result.charts.physicalPallets[0]?.points[0]?.value).toBe(1) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations', { params: query }) +}) +it('keeps measured versions and retries exact requests, without converting absent fuel into zero', async () => { + vi.clearAllMocks() + const wire = { tripNo: 'TR1', recordVersion: '90071992547409931', details: { actualDistanceMiles: '0', actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null } + vi.mocked(http.get).mockResolvedValue({ code: '200', data: wire }) + const record = await getTripOperationsRecord('TR1') + expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] }) + const request = { recordVersion: record.recordVersion, idempotencyKey: 'stable-key', details: { actualDistanceMiles: record.details.actualDistanceMiles, actualFuelLitres: record.details.actualFuelLitres, remark: record.details.remark, evidenceFileIds: [] }, reason: null } + vi.mocked(http.put).mockResolvedValue({ code: '200', data: wire }); await saveTripOperationsRecord('TR1', request) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record', request) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...wire, occurredAt: '2026-10-03T00:00:00Z', reason: '更正' }], total: '1', page: '1', size: '20', pages: '1' } }) + const history = await listTripOperationsRecordHistory('TR1') + expect(history.records[0]?.recordVersion).toBe('90071992547409931') + expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record/history', { params: { page: 1, size: 20 } }) +}) +it('uploads multipart to the trip-scoped endpoint and preserves large file identities', async () => { + vi.clearAllMocks() + const file = new File(['pdf'], 'meter.pdf', { type: 'application/pdf' }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: '3' } }) + expect(await uploadTripOperationsEvidence('TR/1', file)).toMatchObject({ fileId: '9007199254740993', sizeBytes: 3 }) + expect(vi.mocked(http.post).mock.calls[0]![0]).toBe('/platform/v1/last-leg/fleet/operations/TR%2F1/record/files') + expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('file')).toBe(file) + await expect(uploadTripOperationsEvidence('TR1', new File(['html'], 'x.html', { type: 'text/html' }))).rejects.toThrow('PDF') + expect(http.post).toHaveBeenCalledTimes(1) +}) +it('rejects JSON download errors and preserves correlation evidence instead of previewing them', async () => { + const blob = new Blob(['pdf'], { type: 'application/pdf' }); vi.mocked(http.get).mockResolvedValue(blob) + expect(await downloadTripOperationsEvidence('TR1', '81')).toBe(blob) + vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无权读取', requestId: 'request-evidence', traceId: 'trace-evidence' }) + await expect(downloadTripOperationsEvidence('TR1', '81')).rejects.toMatchObject({ code: 'A0403', requestId: 'request-evidence', traceId: 'trace-evidence' }) +}) diff --git a/src/modules/overseas-master-data/api/tripOperations.ts b/src/modules/overseas-master-data/api/tripOperations.ts new file mode 100644 index 00000000..9c7aa87d --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperations.ts @@ -0,0 +1,75 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' +import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes' +import type { CostSummary, CostTotal } from './tripCosts' +export interface TripOperationsQuery { page?: number; size?: number; from: string; to: string; keyword?: string; warehouseId?: string; sourceType?: 'OWN' | 'EXTERNAL'; status?: string } +export interface TripOperationsMoney { currency: string; amount: number } +type OperationsCostTotal = Pick +export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] } +export type OperationsRowCosts = Pick & { totals: OperationsCostTotal[] } +export interface TripOperationsEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number } +export interface TripOperationsMeasured { actualDistanceMiles: number | null; actualFuelLitres: number | null; remark: string | null; evidenceFiles: TripOperationsEvidence[] } +export type TripOperationsMeasuredInput = Omit & { evidenceFileIds: string[] } +export interface TripOperationsSeries { label: string; unit: string; points: { label: string; value: number }[] } +export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] } +export interface TripOperationsRow { + tripNo: string; status: string; vehiclePlate: string | null; driverName: string | null; warehouseName: string | null; departedAt: string; completedAt: string | null; destinations: { key: string; label: string }[] + physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string + externalPalletRates: { currency: string; pricedPalletSpaces: number; agreedAmount: number }[]; measured: TripOperationsMeasured | null; costs: OperationsRowCosts | null +} +export interface TripOperationsSummary { trips: number; inTransit: number; completed: number; physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; costs: OperationsCosts } +export interface TripOperationsResult { page: PageResult; summary: TripOperationsSummary; charts: TripOperationsCharts } +type WireNumbers = { [K in keyof T]: T[K] extends number ? number | string : T[K] } +type WireMoney = { currency: string; amount: string | number } +type WireEvidence = Omit & { sizeBytes: number | string } +type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null; evidenceFiles?: WireEvidence[] } +type WireCostTotal = { currency: CostTotal['currency']; directCost: string | number; ownCost: string | number | null; externalCost: string | number | null } +type WireRow = Omit, 'externalAgreedAmounts' | 'measured' | 'externalPalletRates' | 'costs'> & { externalAgreedAmounts: WireMoney[]; measured: WireMeasured | null; externalPalletRates: { currency: string; pricedPalletSpaces: number | string; agreedAmount: number | string }[]; costs: { allocationBlockReason: string | null; totals: WireCostTotal[] } | null } +type WireSummary = Omit, 'externalAgreedAmounts' | 'costs'> & { externalAgreedAmounts: WireMoney[]; costs: { visible: boolean; unrecordedTrips: string | number | null; unallocatedTrips: string | number | null; totals: WireCostTotal[] } } +const costTotals = (rows: WireCostTotal[]): OperationsCostTotal[] => rows.map(r => ({ ...r, directCost: fleetMoney(r.directCost), ownCost: r.ownCost == null ? null : fleetMoney(r.ownCost), externalCost: r.externalCost == null ? null : fleetMoney(r.externalCost) })) +const money = (rows: WireMoney[]) => rows.map(r => ({ ...r, amount: fleetMoney(r.amount) })) +const counts = (r: WireRow | WireSummary) => ({ physicalPallets: fleetNumber(r.physicalPallets), palletSpaces: fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) }) +const evidence = (r: WireEvidence): TripOperationsEvidence => { + if (typeof r.fileId !== 'string' || !/^[1-9]\d*$/.test(r.fileId) || !r.fileName || !['application/pdf', 'image/jpeg', 'image/png'].includes(r.contentType)) throw new Error('实测凭证信息无效') + return { ...r, sizeBytes: fleetNumber(r.sizeBytes) } +} +const measured = (r: WireMeasured): TripOperationsMeasured => ({ ...r, evidenceFiles: (r.evidenceFiles || []).map(evidence), actualDistanceMiles: r.actualDistanceMiles == null ? null : fleetNumber(r.actualDistanceMiles), actualFuelLitres: r.actualFuelLitres == null ? null : fleetNumber(r.actualFuelLitres) }) +type WireSeries = Omit & { points: { label: string; value: string | number }[] } +type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] } +const series = (values: WireSeries[]) => values.map(s => ({ ...s, points: s.points.map(p => ({ ...p, value: fleetNumber(p.value) })) })) +export async function listTripOperations(query: TripOperationsQuery): Promise { + const result = unwrapApiResponse(await http.get; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query })) + return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } } +} +export const exportTripOperations = (query: TripOperationsQuery) => fleetCsv('/platform/v1/last-leg/fleet/operations/export', query) +export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null } +export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null } +export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured } +type WireRecord = Omit & { recordVersion: string | number; details: WireMeasured } +type WireHistory = Omit & { recordVersion: string | number; details: WireMeasured } +const recordPath = (tripNo: string) => `/platform/v1/last-leg/fleet/operations/${encodeURIComponent(tripNo)}/record` +const record = (v: WireRecord): TripOperationsRecord => ({ ...v, recordVersion: fleetVersion(v.recordVersion), details: measured(v.details) }) +export async function getTripOperationsRecord(tripNo: string) { return record(unwrapApiResponse(await http.get, ApiEnvelope>(recordPath(tripNo)))) } +export async function saveTripOperationsRecord(tripNo: string, request: TripOperationsRecordSave) { return record(unwrapApiResponse(await http.put, ApiEnvelope>(recordPath(tripNo), request))) } +export async function listTripOperationsRecordHistory(tripNo: string, page = 1, size = 20) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${recordPath(tripNo)}/history`, { params: { page, size } })) + return fleetPage({ ...result, records: result.records.map(r => ({ ...r, recordVersion: fleetVersion(r.recordVersion), details: measured(r.details) })) }) +} +export async function uploadTripOperationsEvidence(tripNo: string, file: File): Promise { + if (!file.size || file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) throw new Error('请选择非空PDF、JPG或PNG文件,最大10MiB') + const body = new FormData(); body.append('file', file) + return evidence(unwrapApiResponse(await http.post, ApiEnvelope>(`${recordPath(tripNo)}/files`, body))) +} +export async function downloadTripOperationsEvidence(tripNo: string, fileId: string): Promise { + async function rejectEnvelope(value: unknown, status: number) { + if (value instanceof Blob && value.type.includes('json')) { try { value = JSON.parse(await value.text()) } catch { throw new Error('凭证接口返回了无法识别的错误') } } + if (value && typeof value === 'object' && 'code' in value) { const e = value as ApiEnvelope; throw new ApiResponseError(e.message || '凭证读取失败', e.code, status, e.data, e.requestId, e.traceId) } + } + try { + const value = await http.get(`${recordPath(tripNo)}/files/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' }) + await rejectEnvelope(value, 200) + if (!(value instanceof Blob) || !value.size || !['application/pdf', 'image/jpeg', 'image/png'].includes(value.type) || value.size > 10 * 1024 * 1024) throw new Error('实测凭证下载失败或返回了错误响应') + return value + } catch (cause) { if (axios.isAxiosError(cause)) await rejectEnvelope(cause.response?.data, cause.response?.status || 0); throw cause } +} diff --git a/src/modules/overseas-master-data/api/tripOperationsFinance.test.ts b/src/modules/overseas-master-data/api/tripOperationsFinance.test.ts new file mode 100644 index 00000000..690c583d --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperationsFinance.test.ts @@ -0,0 +1,15 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { getTripOperationsFinance } from './tripOperationsFinance' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn() } })) +beforeEach(() => vi.clearAllMocks()) +it('reads only the requested trip, separates currencies and keeps unknown receipts null', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tripNo: 'TR/A', dispatchNos: ['DS-A'], receiptsVisible: true, attributionStatus: 'ATTRIBUTED', attributionReason: 'frozen', finance: { status: 'AVAILABLE', reason: 'authority', totals: [{ currency: 'CNY', receivableAmount: '0', receivedAmount: null }, { currency: 'USD', receivableAmount: '20.12', receivedAmount: '3.5' }], sources: [], documents: [] } } }) + const result = await getTripOperationsFinance('TR/A') + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations/TR%2FA/finance') + expect(result.finance.totals).toEqual([{ currency: 'CNY', receivableAmount: 0, receivedAmount: null }, { currency: 'USD', receivableAmount: 20.12, receivedAmount: 3.5 }]) +}) +it('rejects a response belonging to another trip', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tripNo: 'OTHER' } }) + await expect(getTripOperationsFinance('TR-A')).rejects.toThrow('车次不匹配') +}) diff --git a/src/modules/overseas-master-data/api/tripOperationsFinance.ts b/src/modules/overseas-master-data/api/tripOperationsFinance.ts new file mode 100644 index 00000000..66763ede --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperationsFinance.ts @@ -0,0 +1,23 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { fleetMoney } from './fleetQuotes' +export interface TripFinanceMoney { currency: string; receivableAmount: number; receivedAmount: number | null } +export interface TripFinanceSource { customerId: string; businessNo: string; sourceNo: string | null; feeItemName: string; sourceVersion: number; currency: string; amount: number; status: string } +export interface TripFinanceDocument { billNo: string; currency: string; receivedAmount: number | null; allocationStatus: string; reason: string } +export interface TripFinanceResult { + tripNo: string; dispatchNos: string[]; receiptsVisible: boolean; attributionStatus: string; attributionReason: string + finance: { status: string; totals: TripFinanceMoney[]; sources: TripFinanceSource[]; documents: TripFinanceDocument[]; reason: string } +} +type Wire = Omit & { finance: Omit & { + totals: { currency: string; receivableAmount: string | number; receivedAmount: string | number | null }[] + sources: (Omit & { amount: string | number })[] + documents: (Omit & { receivedAmount: string | number | null })[] +} } +export async function getTripOperationsFinance(tripNo: string): Promise { + const data = unwrapApiResponse(await http.get, ApiEnvelope>(`/platform/v1/last-leg/fleet/operations/${encodeURIComponent(tripNo)}/finance`)) + if (data.tripNo !== tripNo) throw new Error('财务响应车次不匹配') + return { ...data, finance: { ...data.finance, + totals: data.finance.totals.map(m => ({ ...m, receivableAmount: fleetMoney(m.receivableAmount), receivedAmount: m.receivedAmount == null ? null : fleetMoney(m.receivedAmount) })), + sources: data.finance.sources.map(s => ({ ...s, amount: fleetMoney(s.amount) })), + documents: data.finance.documents.map(d => ({ ...d, receivedAmount: d.receivedAmount == null ? null : fleetMoney(d.receivedAmount) })), + } } +} diff --git a/src/modules/overseas-master-data/api/trips.test.ts b/src/modules/overseas-master-data/api/trips.test.ts new file mode 100644 index 00000000..3db084f6 --- /dev/null +++ b/src/modules/overseas-master-data/api/trips.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError, http } from '@/shared/api/http' +import { + listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, + getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan, +} from './trips' + +vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const base = '/platform/v1/last-leg/fleet/trips' +const longId = '9223372036854775801' +const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, warehouseCode: 'WH', warehouseName: '起运仓', + vehicleId: longId, vehiclePlate: 'TEST-1', driverId: longId, driverName: '司机', driverPhone: '123', + maxPalletSpaces: '30', maxLoadKg: '18000.125', maxVolumeCbm: '90.001', recordVersion: '0', + plannedDepartureAt: '2026-10-02T10:00:00Z', expectedDriverEndAt: '2026-10-03T10:00:00Z', departedAt: null, completedAt: null, + stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, updatedAt: '2026-10-02T08:00:00Z', vehicleRelease: null, driverRelease: null } +const reviewState = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } +const detail = { trip, driverQualification: null, loadingReview: reviewState, dispatches: [{ dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] } +const cargo = { dispatchNo: 'DP-1', customerId: longId, dispatchItemId: longId, warehousePalletId: longId, palletNo: 'PALLET-1', packages: '12', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' } +const load = { palletSpaces: '1', weightKg: '100.125', volumeCbm: '1.005' } +const confirmed = { manifestHash: 'a'.repeat(64), cargo: [cargo], pallets: [{ ...load, warehousePalletId: longId, deliveryIndex: '1' }], + segments: [{ fromIndex: '0', toIndex: '1', occupied: load, remaining: { palletSpaces: '29', weightKg: '17899.875', volumeCbm: '88.995' } }], operatorId: longId, confirmedAt: trip.updatedAt } +const loading = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: confirmed, blockReason: null } +const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: longId, destination: 'ONT8', status: 'DELIVERED', packages: '12', signedPackages: '8', returnedPackages: '4', remainingPackages: '0', terminalAt: trip.updatedAt } +const envelope = (data: T) => ({ code: '200', message: '成功', data }) + +describe('fleet trip API contract', () => { + beforeEach(() => vi.clearAllMocks()) + + it('uses one paged request and keeps long identifiers as exact strings', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' })) + const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId } + const result = await listTrips(query) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query }) + expect(result.total).toBe(121) + expect(result.records[0]).toMatchObject({ vehicleId: longId, driverId: longId, warehouseId: longId, recordVersion: 0, maxPalletSpaces: 30, maxLoadKg: 18000.125 }) + }) + + it('encodes the business number and reads details without prefetching history or loading', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope(detail)) + const result = await getTrip(trip.tripNo) + expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST`) + expect(result.dispatches[0]).toMatchObject({ packageCount: 12, customerId: longId }) + }) + + it('forwards explicit versions and stable idempotency keys for every write without extra reads', async () => { + vi.mocked(http.post).mockResolvedValue(envelope(detail)); vi.mocked(http.put).mockResolvedValue(envelope(detail)) + const draft = { warehouseId: longId, plannedDepartureAt: trip.plannedDepartureAt, expectedDriverEndAt: trip.expectedDriverEndAt, stops: trip.stops, idempotencyKey: 'same-create-key' } + await createTrip(draft); await createTrip(draft) + expect(http.post).toHaveBeenNthCalledWith(1, base, draft); expect(http.post).toHaveBeenNthCalledWith(2, base, draft) + const plan = { recordVersion: 0, plannedDepartureAt: draft.plannedDepartureAt, expectedDriverEndAt: draft.expectedDriverEndAt, stops: draft.stops, reason: '调整' } + await updateTripPlan(trip.tripNo, plan) + expect(http.put).toHaveBeenCalledWith(`${base}/TR%2FTEST/plan`, plan) + const assignment = { recordVersion: 0, vehicleId: longId, driverId: longId, reason: '分配' } + await assignTrip(trip.tripNo, assignment) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/assignment`, assignment) + const binding = { recordVersion: 1, dispatchNo: 'DP-1', reason: '关联' } + await bindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/bind`, binding) + await unbindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/unbind`, binding) + await cancelTrip(trip.tripNo, { recordVersion: 2, reason: '取消' }) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/cancel`, { recordVersion: 2, reason: '取消' }) + const complete = { recordVersion: 4, idempotencyKey: 'complete-key', reason: '核对' } + await completeTrip(trip.tripNo, complete); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/complete`, complete) + const release = { recordVersion: 5, resourceKind: 'DRIVER' as const, actualEndAt: trip.updatedAt, endLocation: '交接点', idempotencyKey: 'release-key', reason: '返程已结束' } + await releaseTripResource(trip.tripNo, release); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/resources/release`, release) + const review = { recordVersion: 6, decision: 'APPROVED' as const } + await reviewTripDispatch(trip.tripNo, 'DP/1', review) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/DP%2F1/review`, review) + await reviewTripLoading(trip.tripNo, review) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/loading-review`, review) + expect(http.get).not.toHaveBeenCalled() + }) + + it('keeps planned capacity separate from actual load and consumes source freshness', async () => { + const plan = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, capacityPlan: confirmed, planValid: false, blockReason: null, availableSegments: confirmed.segments } + vi.mocked(http.get).mockResolvedValueOnce(envelope(plan)); vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...plan, planValid: true, recordVersion: '4' })) + const result = await getTripCapacityPlan(trip.tripNo) + expect(result.planValid).toBe(false); expect(result.capacityPlan?.segments[0]?.remaining.palletSpaces).toBe(29) + expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`) + const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }], reason: '计划核对' } + expect((await confirmTripCapacityPlan(trip.tripNo, body)).recordVersion).toBe(4) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`, body) + }) + + it('preserves source fingerprints and uses server capacity results without recomputing them', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope(loading)); vi.mocked(http.post).mockResolvedValueOnce(envelope(loading)) + const result = await getTripLoading(trip.tripNo) + expect(result.confirmedLoad?.segments[0]?.remaining).toEqual({ palletSpaces: 29, weightKg: 17899.875, volumeCbm: 88.995 }) + expect(result.cargo[0]?.warehousePalletId).toBe(longId) + const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100.125, volumeCbm: 1.005, deliveryIndex: 1 }], reason: '核对实物' } + await confirmTripLoading(trip.tripNo, body) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/loading`, body) + }) + + it('loads completion and immutable history only when explicitly requested', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '5', canComplete: false, blockReason: '另一票未签收', outcomes: [outcome] })) + expect(await getTripCompletion(trip.tripNo)).toMatchObject({ recordVersion: 5, canComplete: false, blockReason: '另一票未签收', outcomes: [{ packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0 }] }) + vi.mocked(http.get).mockResolvedValueOnce(envelope({ page: '2', size: '10', total: '12', pages: '2', records: [{ recordVersion: '5', action: 'COMPLETE', reason: null, operatorId: longId, occurredAt: trip.updatedAt, + snapshot: { trip, externalBookings: [], capacityPlan: null, loadingReview: reviewState, dispatches: detail.dispatches, confirmedLoad: confirmed, completion: { idempotencyKey: 'key', requestHash: 'hash', completedAt: trip.updatedAt, recordedAt: trip.updatedAt, operatorId: longId, outcomes: [outcome] } } }] })) + const history = await listTripHistory(trip.tripNo, 2, 10) + expect(http.get).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/history`, { params: { page: 2, size: 10 } }) + expect(history.records[0]?.snapshot.completion?.outcomes[0]?.packages).toBe(12) + expect(history.records[0]?.snapshot.completion?.outcomes[0]).toMatchObject({ signedPackages: 8, returnedPackages: 4, remainingPackages: 0 }) + expect(history.records[0]?.snapshot.trip.recordVersion).toBe(0) + }) + + it('rejects missing or unsafe versions and preserves backend conflict diagnostics', async () => { + for (const version of [null, '', '9007199254740993', '-1']) { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, trip: { ...trip, recordVersion: version } })) + await expect(getTrip(trip.tripNo)).rejects.toThrow() + } + const conflict = new ApiResponseError('车次已修改', '409', 200, null, 'request-1', 'trace-1') + vi.mocked(http.post).mockRejectedValueOnce(conflict) + await expect(cancelTrip(trip.tripNo, { recordVersion: 0, reason: '取消' })).rejects.toBe(conflict) + }) +}) diff --git a/src/modules/overseas-master-data/api/trips.ts b/src/modules/overseas-master-data/api/trips.ts new file mode 100644 index 00000000..157f7c45 --- /dev/null +++ b/src/modules/overseas-master-data/api/trips.ts @@ -0,0 +1,113 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { normalizeBooking, type WireBooking } from './tripBookings' +import type { PageResult } from './overseasMasterData' +import type { + FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding, + TripCargo, TripLoad, TripMeasurement, TripConfirmedLoad, TripLoading, TripLoadConfirmation, + TripOutcome, TripCompletionInspection, TripComplete, TripResourceRelease, TripHistory, TripReviewState, TripReviewCommand, TripCapacityPlanning, +} from '../tripTypes' + +const base = '/platform/v1/last-leg/fleet/trips' +const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}` +type Numeric = number | string +type WireTrip = Omit & { + recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null +} +type WireReview = Omit & { recordVersion: Numeric | null } +type WireBinding = Omit & { packageCount: Numeric; review: WireReview } +type WireDetail = Omit & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview } +type WirePage = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +type WireCargo = Omit & { packages: Numeric } +type WireLoad = { [K in keyof TripLoad]: Numeric } +type WireMeasurement = Omit & WireLoad & { deliveryIndex: Numeric } +type WireConfirmed = Omit & { + cargo: WireCargo[]; pallets: WireMeasurement[] + segments: { fromIndex: Numeric; toIndex: Numeric; occupied: WireLoad; remaining: WireLoad }[] +} +type WireLoading = Omit & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null } +type WirePlanning = Omit & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] } +type WireOutcome = Omit & { packages: Numeric; signedPackages: Numeric; returnedPackages: Numeric; remainingPackages: Numeric } +type WireInspection = Omit & { recordVersion: Numeric; outcomes: WireOutcome[] } +type WireHistory = Omit & { + recordVersion: Numeric + snapshot: { externalBookings: WireBooking[]; capacityPlan: WireConfirmed | null; trip: WireTrip; dispatches: WireBinding[]; confirmedLoad: WireConfirmed | null; loadingReview: WireReview; completion: (Omit, 'outcomes'> & { outcomes: WireOutcome[] }) | null } +} + +function number(value: Numeric): number { + if (typeof value !== 'number' && typeof value !== 'string') throw new Error('车次接口缺少有效数值字段') + if (typeof value === 'string' && !value.trim()) throw new Error('车次接口返回了空的数值字段') + const result = Number(value) + if (!Number.isFinite(result) || result < 0) throw new Error('车次接口返回了无效数值') + return result +} +function integer(value: Numeric): number { + const result = number(value) + if (!Number.isSafeInteger(result)) throw new Error('车次接口返回了无效分页、数量或版本') + return result +} +const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer(value.recordVersion), + maxPalletSpaces: value.maxPalletSpaces == null ? null : integer(value.maxPalletSpaces), + maxLoadKg: value.maxLoadKg == null ? null : number(value.maxLoadKg), maxVolumeCbm: value.maxVolumeCbm == null ? null : number(value.maxVolumeCbm) }) +const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) }) +const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), review: review(value.review) }) +const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), loadingReview: review(value.loadingReview) }) +const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) }) +const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages), + signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) }) +const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }) +function confirmed(value: WireConfirmed | null): TripConfirmedLoad | null { + return value == null ? null : { ...value, cargo: value.cargo.map(cargo), + pallets: value.pallets.map(row => ({ ...row, ...load(row), deliveryIndex: integer(row.deliveryIndex) })), + segments: value.segments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) } +} +const loading = (value: WireLoading): TripLoading => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), confirmedLoad: confirmed(value.confirmedLoad) }) +function page(value: WirePage, map: (row: W) => T): PageResult { + return { records: value.records.map(map), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +export interface TripQuery { page: number; size: number; keyword?: string; status?: TripStatus; warehouseId?: string } +export async function listTrips(query: TripQuery): Promise> { + return page(unwrapApiResponse(await http.get>, ApiEnvelope>>(base, { params: query })), trip) +} +export async function getTrip(tripNo: string): Promise { + return detail(unwrapApiResponse(await http.get, ApiEnvelope>(path(tripNo)))) +} +async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise { + return detail(unwrapApiResponse(await http.post, ApiEnvelope>(url, body))) +} +export const createTrip = (body: TripDraft) => postDetail(base, body) +export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body) +export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body) +export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body) +export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body) +export const unbindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/unbind`, body) +export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason: string }) => postDetail(`${path(tripNo)}/cancel`, body) +export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body) +export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body) +export async function updateTripPlan(tripNo: string, body: TripPlan): Promise { + return detail(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/plan`, body))) +} +const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) }) +export async function getTripCapacityPlan(tripNo: string): Promise { + return planning(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/capacity-plan`))) +} +export async function confirmTripCapacityPlan(tripNo: string, body: TripLoadConfirmation): Promise { + return planning(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/capacity-plan`, body))) +} +export async function getTripLoading(tripNo: string): Promise { + return loading(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/loading`))) +} +export async function confirmTripLoading(tripNo: string, body: TripLoadConfirmation): Promise { + return loading(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/loading`, body))) +} +export async function getTripCompletion(tripNo: string): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/completion`)) + return { ...value, canComplete: value.canComplete === true, recordVersion: integer(value.recordVersion), outcomes: value.outcomes.map(outcome) } +} +export async function listTripHistory(tripNo: string, currentPage = 1, size = 20): Promise> { + const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${path(tripNo)}/history`, { params: { page: currentPage, size } })) + return page(value, row => ({ ...row, recordVersion: integer(row.recordVersion), snapshot: { + trip: trip(row.snapshot.trip), dispatches: row.snapshot.dispatches.map(binding), confirmedLoad: confirmed(row.snapshot.confirmedLoad), + externalBookings: row.snapshot.externalBookings.map(normalizeBooking), loadingReview: review(row.snapshot.loadingReview), capacityPlan: confirmed(row.snapshot.capacityPlan), + completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) }, + } })) +} diff --git a/src/modules/overseas-master-data/api/vehicleCare.test.ts b/src/modules/overseas-master-data/api/vehicleCare.test.ts new file mode 100644 index 00000000..3908cae4 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleCare.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http, ApiResponseError } from '@/shared/api/http' +import { listVehicleCare, createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto, downloadVehicleCarePhoto, listVehicleCareFinance, submitVehicleCareFinance } from './vehicleCare' +import type { VehicleCareDraft } from '../vehicleCareTypes' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const id = '9223372036854775701', base = `/platform/v1/last-leg/fleet/vehicles/${id}/records` +const details: VehicleCareDraft = { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null } +const wire = { recordNo: 'VR-ONE', vehicleId: id, vehiclePlate: 'TEST', recordVersion: '2', details: { ...details, odometer: '100', fuelQuantity: '28.6' }, files: [{ fileId: '9223372036854775702', fileName: 'meter.png', contentType: 'image/png', sizeBytes: '8' }], updatedBy: 'operator', updatedAt: '2026-01-01T00:00:00Z' } +const ok = (data: unknown) => ({ code: '200', message: '成功', data }) +beforeEach(() => vi.clearAllMocks()) +it('queries financial status once for the visible records and preserves unknown amounts', async () => { + vi.mocked(http.post).mockResolvedValue(ok([{ recordNo: 'VR-ONE', status: 'PENDING_FINANCE', sourceVersion: '90071992547409931', submittedRecordVersion: '2' }])) + const result = await listVehicleCareFinance(id, ['VR-ONE']) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/finance-status`, { recordNos: ['VR-ONE'] }) + expect(result[0]).toMatchObject({ status: 'PENDING_FINANCE', sourceVersion: '90071992547409931', finance: null }) + await listVehicleCareFinance(id, []); expect(http.post).toHaveBeenCalledTimes(1) +}) +it('rejects incomplete or cross-record financial status instead of pretending it is unlinked', async () => { + vi.mocked(http.post).mockResolvedValueOnce(ok([])).mockResolvedValueOnce(ok([{ recordNo: 'VR-OTHER', status: 'PAID' }])) + await expect(listVehicleCareFinance(id, ['VR-ONE'])).rejects.toThrow('全部') + await expect(listVehicleCareFinance(id, ['VR-ONE'])).rejects.toThrow('归属') +}) +it('submits the saved version and stable key without sending amounts or a customer', async () => { + vi.mocked(http.post).mockResolvedValue(ok({ recordNo: 'VR-ONE', sourceVersion: '1', submittedRecordVersion: '2', replayed: false, status: 'PENDING_FINANCE' })) + const request = { recordVersion: 2, supplierId: '9223372036854775703', businessDate: '2026-01-01', idempotencyKey: 'same-finance-key' } + const result = await submitVehicleCareFinance(id, 'VR-ONE', request) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/VR-ONE/finance`, request) + expect(result.status).toBe('PENDING_FINANCE') +}) +it('normalizes omitted nullable incident fields without injecting maintenance defaults', async () => { + const repair = { ...wire, details: { recordType: 'REPAIR', occurredAt: details.occurredAt, + incident: { description: '轮胎维修', item: '换胎', place: '现场', processingStatus: 'PENDING', reportedCost: '125.50', currency: 'USD' } } } + vi.mocked(http.get).mockResolvedValue(ok({ records: [repair], page: 1, size: 10, total: 1, pages: 1 })) + const result = (await listVehicleCare(id, 'REPAIR')).records[0]! + expect(result.details).toMatchObject({ distanceUnit: null, odometer: null, fuelUnit: null, verificationStatus: null, + lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, remark: null, + incident: { handler: null, exceptionType: null, reportedCost: 125.5, currency: 'USD' } }) +}) +it('keeps vehicle and file IDs exact while normalizing values without changing units', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ records: [wire], page: '1', size: '10', total: '1', pages: '1' })) + const result = await listVehicleCare(id, 'FUEL') + expect(result.records[0]).toMatchObject({ vehicleId: id, recordVersion: 2, details, files: [{ fileId: '9223372036854775702', sizeBytes: 8 }] }) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: { recordType: 'FUEL', page: 1, size: 10 } }) +}) +it('preserves creation and upload keys and sends explicit correction version and reason', async () => { + vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire)) + await createVehicleCare(id, 'same-create-key', details) + await updateVehicleCare(id, 'VR-ONE', 2, '核对里程', details) + const photo = new File(['photo'], 'meter.png', { type: 'image/png' }) + await uploadVehicleCarePhoto(id, 'VR-ONE', 3, 'same-photo-key', photo) + expect(http.post).toHaveBeenNthCalledWith(1, base, { idempotencyKey: 'same-create-key', details }) + expect(http.put).toHaveBeenCalledWith(`${base}/VR-ONE`, { recordVersion: 2, reason: '核对里程', details }) + const [url, form] = vi.mocked(http.post).mock.calls[1]! + expect(url).toBe(`${base}/VR-ONE/files`); expect(form).toBeInstanceOf(FormData) + expect((form as FormData).get('recordVersion')).toBe('3'); expect((form as FormData).get('idempotencyKey')).toBe('same-photo-key') + expect((form as FormData).get('file')).toBe(photo) +}) +it('rejects JSON business errors and non-image bodies instead of opening them as photos', async () => { + vi.mocked(http.get).mockResolvedValueOnce(new Blob(['image'], { type: 'image/png' })) + .mockResolvedValueOnce({ code: 'A0404', message: '图片不属于记录', requestId: 'test-request' }) + .mockResolvedValueOnce(new Blob(['html'], { type: 'text/html' })) + expect((await downloadVehicleCarePhoto(id, 'VR-ONE', '71')).type).toBe('image/png') + await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '72')).rejects.toMatchObject({ name: 'ApiResponseError', code: 'A0404', requestId: 'test-request' } satisfies Partial) + await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '73')).rejects.toThrow('格式无效') +}) +it('keeps unknown and zero incident costs distinct and preserves independent payment state', async () => { + const incident = { description: '救援完成', item: '拖车', place: '现场', handler: '处理人', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: '0.00', currency: 'USD' } + const rescue = { ...wire, paymentStatus: 'UNLINKED', details: { ...details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, incident } } + vi.mocked(http.get).mockResolvedValue(ok({ records: [rescue, { ...rescue, recordNo: 'VR-TWO', details: { ...rescue.details, incident: { ...incident, reportedCost: null, currency: null } } }], page: 1, size: 10, total: 2, pages: 1 })) + const result = await listVehicleCare(id, 'RESCUE') + expect(result.records[0]).toMatchObject({ paymentStatus: 'UNLINKED', details: { odometer: null, incident: { reportedCost: 0, currency: 'USD' } } }) + expect(result.records[1]?.details.incident?.reportedCost).toBeNull() +}) diff --git a/src/modules/overseas-master-data/api/vehicleCare.ts b/src/modules/overseas-master-data/api/vehicleCare.ts new file mode 100644 index 00000000..13ae2bd5 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleCare.ts @@ -0,0 +1,136 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { VehicleCareDraft, VehicleCareRecord, VehicleCareType, VehicleIncident } from '../vehicleCareTypes' +import type { PageResult } from './overseasMasterData' + +type Numeric = number | string +type Wire = Omit & { + recordVersion: Numeric + details: Pick & Partial> & { + odometer?: Numeric | null; lastServiceOdometer?: Numeric | null; nextServiceOdometer?: Numeric | null; fuelQuantity?: Numeric | null + incident?: (Pick & Partial> & { reportedCost?: Numeric | null }) | null + } + files: Array & { sizeBytes: Numeric }> +} +const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}` +export const vehicleCareFinanceSubmitPermission = 'platform:overseas-master-data:vehicle:record-finance-submit' +export type VehicleCareFinanceState = 'UNLINKED' | 'PENDING_FINANCE' | 'FINANCE_ATTENTION' | 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED' +export interface VehicleCareFinanceStatus { + recordNo: string; status: VehicleCareFinanceState; sourceVersion: string | null; submittedRecordVersion: string | null + finance: { costNo: string; status: string; currency: 'CNY'; supplierName: string; paidAmount: number; outstandingAmount: number | null; + source: { originalCurrency: 'CNY' | 'USD'; originalAmount: number; amount: number; businessDate: string; exchangeRate: number; exchangeRateMonth: string } } | null +} +export interface VehicleCareFinanceSubmit { recordVersion: number; supplierId: string; businessDate: string; idempotencyKey: string } +export interface VehicleCareFinanceSubmitted { recordNo: string; sourceVersion: string; submittedRecordVersion: string; replayed: boolean; status: 'PENDING_FINANCE' } +export const vehicleCareFinanceLabels: Record = { + UNLINKED: '未提交财务', PENDING_FINANCE: '待财务接收', FINANCE_ATTENTION: '财务接收需处理', PENDING: '待财务入账', APPROVED: '已入账 · 未付款', + PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废', +} +function financeVersion(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('财务版本无效') + return String(value) +} +function financeAmount(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('财务金额无效') + const [whole, fraction = ''] = String(value).split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('财务金额超过精确展示范围') + return Number(value) +} +export async function listVehicleCareFinance(vehicleId: string, recordNos: string[]): Promise { + if (!recordNos.length) return [] + type Cost = NonNullable + type StatusWire = Omit & { + sourceVersion?: Numeric | null; submittedRecordVersion?: Numeric | null + finance?: (Omit & { paidAmount: Numeric; outstandingAmount?: Numeric | null; + source: Omit & { originalAmount: Numeric; amount: Numeric; exchangeRate: Numeric } }) | null + } + const response = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId)}/finance-status`, { recordNos })) + const seen = new Set() + const result = response.map(value => { + if (!recordNos.includes(value.recordNo) || seen.has(value.recordNo) || !Object.hasOwn(vehicleCareFinanceLabels, value.status)) throw new Error('财务状态归属或内容无效') + seen.add(value.recordNo) + const cost = value.finance + if (!['UNLINKED', 'PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(value.status) && (!cost || cost.status !== value.status)) throw new Error('财务入账或付款状态缺少真实依据') + if (cost && cost.currency !== 'CNY') throw new Error('财务结算币种无效') + return { ...value, sourceVersion: value.sourceVersion == null ? null : financeVersion(value.sourceVersion), + submittedRecordVersion: value.submittedRecordVersion == null ? null : financeVersion(value.submittedRecordVersion), + finance: cost ? { ...cost, paidAmount: financeAmount(cost.paidAmount), outstandingAmount: cost.outstandingAmount == null ? null : financeAmount(cost.outstandingAmount), + source: { ...cost.source, originalAmount: financeAmount(cost.source.originalAmount), amount: financeAmount(cost.source.amount), exchangeRate: decimal(cost.source.exchangeRate) } } : null } + }) + if (seen.size !== new Set(recordNos).size) throw new Error('财务状态未返回全部所选记录') + return result +} +export async function submitVehicleCareFinance(vehicleId: string, recordNo: string, request: VehicleCareFinanceSubmit): Promise { + type Wire = Omit & { sourceVersion: Numeric; submittedRecordVersion: Numeric } + const response = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId, recordNo)}/finance`, request)) + if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请使用原请求重试') + return { ...response, sourceVersion: financeVersion(response.sourceVersion), submittedRecordVersion: financeVersion(response.submittedRecordVersion) } +} +export async function listVehicleCareSuppliers(keyword = ''): Promise> { + type Supplier = { id: Numeric; name: string; status: string } + const response = unwrapApiResponse(await http.get, ApiEnvelope<{ records: Supplier[] }>>('/platform/v1/suppliers', + { params: { page: 1, size: 50, status: 'ACTIVE', keyword } })) + return response.records.filter(row => row.status === 'ACTIVE').map(row => ({ id: financeVersion(row.id), name: row.name })) +} +function integer(value: Numeric): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) throw new Error('运维接口返回了无效的分页或版本') + return result +} +function decimal(value: Numeric): number { + const result = Number(value) + if (value === '' || !Number.isFinite(result) || result < 0) throw new Error('运维接口返回了无效数值') + return result +} +function record(value: Wire): VehicleCareRecord { + const optional = (number: Numeric | null | undefined) => number == null ? null : decimal(number) + const details = value.details, incident = details.incident + return { ...value, recordVersion: integer(value.recordVersion), paymentStatus: value.paymentStatus ?? null, details: { + recordType: details.recordType, occurredAt: details.occurredAt, distanceUnit: details.distanceUnit ?? null, + odometer: optional(details.odometer), lastServiceOdometer: optional(details.lastServiceOdometer), + nextServiceOdometer: optional(details.nextServiceOdometer), fuelQuantity: optional(details.fuelQuantity), + fuelUnit: details.fuelUnit ?? null, verificationStatus: details.verificationStatus ?? null, remark: details.remark ?? null, + incident: incident ? { description: incident.description, processingStatus: incident.processingStatus, + item: incident.item ?? null, place: incident.place ?? null, handler: incident.handler ?? null, + exceptionType: incident.exceptionType ?? null, reportedCost: optional(incident.reportedCost), currency: incident.currency ?? null } : null }, + files: value.files.map(file => ({ ...file, sizeBytes: integer(file.sizeBytes) })) } +} +export async function listVehicleCare(vehicleId: string, recordType: VehicleCareType, page = 1, size = 10): Promise> { + type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } + const result = unwrapApiResponse(await http.get, ApiEnvelope>(path(vehicleId), { params: { recordType, page, size } })) + return { records: result.records.map(record), page: integer(result.page), size: integer(result.size), total: integer(result.total), pages: integer(result.pages) } +} +export async function createVehicleCare(vehicleId: string, idempotencyKey: string, details: VehicleCareDraft): Promise { + return record(unwrapApiResponse(await http.post, ApiEnvelope>(path(vehicleId), { idempotencyKey, details }))) +} +export async function getVehicleCare(vehicleId: string, recordNo: string): Promise { + return record(unwrapApiResponse(await http.get, ApiEnvelope>(path(vehicleId, recordNo)))) +} +export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise { + return record(unwrapApiResponse(await http.put, ApiEnvelope>(path(vehicleId, recordNo), { recordVersion, reason, details }))) +} +export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise { + const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file) + return record(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId, recordNo)}/files`, form))) +} +async function downloadError(value: unknown, status: number): Promise { + if (value instanceof Blob && value.type.includes('json')) { + try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } + } + if (value && typeof value === 'object' && 'code' in value) { + const error = value as ApiEnvelope + throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) + } +} +export async function downloadVehicleCarePhoto(vehicleId: string, recordNo: string, fileId: string): Promise { + try { + const blob = await http.get(`${path(vehicleId, recordNo)}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' }) + await downloadError(blob, 200) + if (!(blob instanceof Blob) || !blob.size || !['image/png', 'image/jpeg'].includes(blob.type)) throw new Error('照片为空或格式无效') + return blob + } catch (cause) { + if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0) + throw cause + } +} diff --git a/src/modules/overseas-master-data/api/vehicleFulfillment.test.ts b/src/modules/overseas-master-data/api/vehicleFulfillment.test.ts new file mode 100644 index 00000000..67ce90f3 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleFulfillment.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listVehicleDeliveries, listVehicleAssignmentChanges } from './vehicleFulfillment' +vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data })) +beforeEach(() => { vi.clearAllMocks() }) +it('encodes the stable vehicle key and adapts serialized pagination without a plate or customer override', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ deliveryNo: 'DL-1' }], page: '1', size: '10', total: '1', pages: '1' } }) + const result=await listVehicleDeliveries('900719925474099301') + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/vehicles/900719925474099301/deliveries', { params: { page: 1, size: 10 } }) + expect(result.total).toBe(1) +}) +it('history preserves large serialized versions and rejects invalid pagination', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ tripNo:'TR-1', recordVersion:'900719925474099301' }], page:'1',size:'10',total:'1',pages:'1' } }) + expect((await listVehicleAssignmentChanges('8')).records[0]?.recordVersion).toBe('900719925474099301') + vi.mocked(http.get).mockResolvedValue({ data: { records: [], page:'1',size:'10',total:'not-a-number',pages:'1' } }) + await expect(listVehicleAssignmentChanges('8')).rejects.toThrow('分页数值') +}) diff --git a/src/modules/overseas-master-data/api/vehicleFulfillment.ts b/src/modules/overseas-master-data/api/vehicleFulfillment.ts new file mode 100644 index 00000000..2ab7d149 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleFulfillment.ts @@ -0,0 +1,33 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export interface VehicleDelivery { + deliveryNo: string; dispatchNo: string; tripNo: string; bolNo: string | null; customerName: string | null + destinationType: string; destinationWarehouseCode: string | null; destinationWarehouseName: string | null + deliveryAddress: string | null; plannedDeliveryAt: string | null; status: string; relationship: 'PLANNED' | 'DEPARTED' +} +export interface VehicleAssignmentChange { + tripNo: string; recordVersion: string; occurredAt: string; reason: string | null + previousVehiclePlate: string | null; vehiclePlate: string | null; previousDriverName: string | null; driverName: string | null + relationship: 'REPLACED_OUT' | 'REPLACED_IN' | 'DRIVER_CHANGED'; dispatchNos: string[] +} +type Numeric = string | number +type WirePage = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +type WireChange = Omit & { recordVersion: Numeric } +function integer(value: Numeric) { + const number = Number(value) + if (value == null || value === '' || !Number.isSafeInteger(number) || number < 0) throw new Error('车辆履约接口返回无效分页数值') + return number +} +function page(value: WirePage, normalize: (row: W) => T): PageResult { + return { records: value.records.map(normalize), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +const base = (vehicleId: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}` +export async function listVehicleDeliveries(vehicleId: string, currentPage = 1, size = 10) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(vehicleId)}/deliveries`, { params: { page: currentPage, size } })) + return page(result, row => row) +} +export async function listVehicleAssignmentChanges(vehicleId: string, currentPage = 1, size = 10) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(vehicleId)}/assignment-history`, { params: { page: currentPage, size } })) + return page(result, row => ({ ...row, recordVersion: String(row.recordVersion) })) +} diff --git a/src/modules/overseas-master-data/bookingTypes.ts b/src/modules/overseas-master-data/bookingTypes.ts new file mode 100644 index 00000000..eda6cd6d --- /dev/null +++ b/src/modules/overseas-master-data/bookingTypes.ts @@ -0,0 +1,24 @@ +export interface FleetCustomerOption { id: string; code: string; name: string; contactName: string | null; contactPhone: string | null } +export interface TripBookingCargo { + destinationKey: string; cargoName: string | null; packageCount: number | null; palletSpaces: number | null; + weightKg: number | null; volumeCbm: number | null; priceBasis: 'PALLET' | 'ORDER'; unitPrice: number | null; + currency: string | null; contactName: string | null; contactPhone: string | null; remark: string | null; +} +export interface TripBooking { offerNo: string | null; expectedPalletCount: number | null; bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string; + status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'; cargo: TripBookingCargo; destinationLabel: string; + agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null; quoteSource: import('./api/fleetQuoteCandidates').FleetQuoteSourceSnapshot | null } +export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null } +export interface TripBookingUpdate { tripRecordVersion: number; recordVersion: number; cargo: TripBookingCargo; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null } +export interface TripBookingCommand { tripRecordVersion: number; recordVersion: number; idempotencyKey: string; reason?: string } +export interface TripBookingResult { tripRecordVersion: number; booking: TripBooking; replayed: boolean } +export interface TripBookingList { tripRecordVersion: number; records: TripBooking[] } +export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' } as const + +export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null } +export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number } +export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number } +export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string } +export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null } +export interface TripIncomingStart extends Omit { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null } +export interface TripIncomingAccept extends Omit { receiptHash: string; pallets: TripPalletSpaces[]; unitPrice: number } +export interface TripBookingBind extends Omit { dispatchNo: string } diff --git a/src/modules/overseas-master-data/capacityOfferTypes.ts b/src/modules/overseas-master-data/capacityOfferTypes.ts new file mode 100644 index 00000000..055c599f --- /dev/null +++ b/src/modules/overseas-master-data/capacityOfferTypes.ts @@ -0,0 +1,41 @@ +import type { TripBookingCargo } from './bookingTypes' +import type { FleetQuoteReference, FleetQuoteSourceSnapshot } from './api/fleetQuoteCandidates' + +export interface OfferPage { records: T[]; page: number; size: number; total: number; pages: number } +export interface CapacityLoad { palletSpaces: number; weightKg: number; volumeCbm: number } +export interface OfferDestination { destinationKey: string; name: string; deliveryIndex: number; remaining: CapacityLoad | null } +export interface OfferSegment { fromIndex: number; toIndex: number; fromName: string; toName: string; planned: CapacityLoad | null; reserved: CapacityLoad | null; remaining: CapacityLoad | null } +export interface CapacityOffer { + offerNo: string; tripNo: string; status: 'DRAFT' | 'OPEN' | 'PAUSED' | 'CLOSED'; recordVersion: number; tripRecordVersion: number + warehouseId: string; warehouseName: string; plannedDepartureAt: string; cutoffAt: string + contactName: string | null; contactPhone: string | null; remark: string | null + destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null + actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean } +} +export interface CapacityInquiry { + bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED' + recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string + customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK' + deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'; estimatedArrivalAt: string | null; expectedPalletCount: number | null + cargo: TripBookingCargo; destinationLabel: string; agreedAmount: number | null; quoteSource: FleetQuoteSourceSnapshot | null + businessOrderNo: string | null; incomingNo: string | null; dispatchNo: string | null; updatedAt: string + actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null } +} +export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage } +export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string } +export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' } +export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string } +export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string } +export interface InquiryWrite { + tripRecordVersion: number; offerRecordVersion: number; recordVersion: number | null; customerId: string + businessType: CapacityInquiry['businessType']; deliveryMode: CapacityInquiry['deliveryMode']; estimatedArrivalAt: string | null + expectedPalletCount: number | null; cargo: TripBookingCargo; quoteRef: FleetQuoteReference | null; idempotencyKey: string +} +export interface InquiryCommand extends OfferCommand { recordVersion: number } +export interface OfferResult { offer: CapacityOffer; replayed: boolean } +export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry } +export interface OfferCustomerOption { id: string; code: string; name: string } +export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' } +export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' } +export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' +export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对' diff --git a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts new file mode 100644 index 00000000..07ad3b5b --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts @@ -0,0 +1,105 @@ +import ElementPlus, { ElMessageBox } from 'element-plus' +import { createPinia } from 'pinia' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { createMemoryHistory, createRouter, RouterView } from 'vue-router' +import CapacityInquiryDialog from './CapacityInquiryDialog.vue' +import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue' +import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue' +import type { CapacityOffer } from '../capacityOfferTypes' +import type { FleetTrip } from '../tripTypes' +import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' +import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers' +import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace' + +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), listCapacityInquiries: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn(), commandCapacityInquiry: vi.fn() })) +const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } } +const wrappers: VueWrapper[] = [] +beforeEach(() => { vi.clearAllMocks(); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) }) +afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = ''; vi.restoreAllMocks() }) +function mountDialog(kind: 'inquiry' | 'settings') { + const pinia = createPinia() + const wrapper = kind === 'inquiry' + ? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } }) + : mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus] } }) + wrappers.push(wrapper) + return { wrapper, pinia } +} +async function continueEditing() { + const message = [...document.querySelectorAll('.el-message-box')].at(-1)! + expect(message).not.toBeNull() + const button = [...message.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续填写')! + expect(button).toBeTruthy(); button.click(); await flushPromises() + await new Promise(resolve => setTimeout(resolve, 50)); await flushPromises() +} +it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited input visible through cancel, Escape and X vetoes', async kind => { + const { wrapper, pinia } = mountDialog(kind) + if (kind === 'settings') await wrapper.setProps({ modelValue: true }) + await flushPromises() + const input = wrapper.find(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]') + expect(input.exists()).toBe(true) + await input.setValue('UI-未保存检查'); await flushPromises() + const assertPreserved = () => { + expect(wrapper.find('.app-dialog').isVisible()).toBe(true) + expect(input.element.isConnected).toBe(true) + expect(input.element.value).toBe('UI-未保存检查') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1) + } + await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises() + assertPreserved(); await continueEditing(); assertPreserved() + input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises() + assertPreserved(); await continueEditing(); assertPreserved() + await wrapper.get('.el-dialog__headerbtn').trigger('click'); await flushPromises() + assertPreserved(); await continueEditing(); assertPreserved() + await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises() + const message = [...document.querySelectorAll('.el-message-box')].at(-1)! + const accept = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '确认关闭') as HTMLButtonElement + accept.click(); await flushPromises() + expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) +}) + +it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => { + const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm') + const router = createRouter({ history: createMemoryHistory(), routes: [ + { path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '

运力列表

' }, meta: { requiresAuth: true } }, + { path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } }, + { path: '/outside', name: 'outside', component: { template: '

外部页面

' }, meta: { workspaceTab: false } }, + ] }) + // Match the application's intentional policy: cross-workspace switching skips the global prompt. + router.beforeEach(async (to, from) => { + const fromId = workspaceRouteId(from) + if (!fromId || isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId || !registry.findForWorkspace(fromId).length) return true + try { await ElMessageBox.confirm('当前页面有未保存内容', '全局离开确认', { confirmButtonText: '放弃并继续', cancelButtonText: '继续填写' }); registry.removeWorkspaces([fromId]); return true } catch { return false } + }) + const Host = defineComponent({ components: { RouterView }, template: '' }) + await router.push('/pool'); await router.isReady() + const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper) + await router.push('/pool/CO-CLOSE'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises() + const input = wrapper.get('input[placeholder="选填"]') + await input.setValue('UI-修复后保留输入'); await flushPromises() + input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing() + confirms.mockClear() + router.back(); await flushPromises() + expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') + expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true) + await continueEditing() + expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') + expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE') + confirms.mockClear(); router.back(); await flushPromises() + const message = [...document.querySelectorAll('.el-message-box')].at(-1)! + const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement + expect(leave).toBeTruthy(); leave.click(); await flushPromises() + expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers') + expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE') + await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises() + await wrapper.get('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises() + confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises() + expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认') + await continueEditing(); await navigation + expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') +}) diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts new file mode 100644 index 00000000..a44c4d2f --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts @@ -0,0 +1,61 @@ +import ElementPlus, { ElMessage } from 'element-plus' +import { createPinia } from 'pinia' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import CapacityInquiryDialog from './CapacityInquiryDialog.vue' +import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers' +import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes' +import type { FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates' + +const permissions = vi.hoisted(() => ({ values: new Set() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permissions.values.has(permission) }) })) +vi.mock('../api/capacityOffers', () => ({ listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn() })) +const offer: CapacityOffer = { offerNo: 'CO-QUOTE-POSITIVE', tripNo: 'TR-QUOTE-POSITIVE', status: 'OPEN', recordVersion: 4, tripRecordVersion: 7, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: { palletSpaces: 8, weightKg: 1000, volumeCbm: 20 } }], segments: [], capacityKnown: true, canCreateInquiry: true, canConfirm: true, blockReason: null, actions: { canEdit: false, canPublish: false, canPause: false, canResume: false, canClose: false } } +const wrappers: VueWrapper[] = [] +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(listOfferCustomers).mockResolvedValue([{ id: '19', code: 'C-19', name: '报价测试客户' }]) +}) +afterEach(async () => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); ElMessage.closeAll(); await flushPromises(); document.body.innerHTML = '' }) +function formItem(wrapper: VueWrapper, label: string) { return wrapper.findAll('.el-form-item').find(item => item.find('.el-form-item__label').text() === label)! } +async function select(wrapper: VueWrapper, label: string, value: string) { + formItem(wrapper, label).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value) + await flushPromises() +} +const scenarios = [ + { actor: 'sales', priceBasis: 'PALLET' as const, permissions: ['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:quote:list'] }, + { actor: 'manager', priceBasis: 'ORDER' as const, permissions: ['platform:fleet:capacity-offer:manage', 'platform:fleet:quote:list'] }, +] +it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated-price saving for $actor', async scenario => { + permissions.values = new Set(scenario.permissions) + const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' } + vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 }) + vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => { + const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } } + return { offer, inquiry, replayed: false } + }) + const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper) + await flushPromises() + expect(listOfferQuotes).not.toHaveBeenCalled() + await select(wrapper, '委托客户', '19'); await select(wrapper, '本车卸货站', 'FBA:ONT8'); await select(wrapper, '币种', 'CNY'); await select(wrapper, '计价方式', scenario.priceBasis) + expect(listOfferQuotes).not.toHaveBeenCalled() + const quotePanel = wrapper.get('section[aria-label="报价来源"]') + await quotePanel.findAll('button').find(button => button.text() === '查询报价库')!.trigger('click'); await flushPromises() + const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, page: 1, size: 20 } + expect(listOfferQuotes).toHaveBeenCalledExactlyOnceWith(offer.offerNo, criteria) + await quotePanel.findAll('button').find(button => button.text() === '下一页')!.trigger('click'); await flushPromises() + expect(listOfferQuotes).toHaveBeenNthCalledWith(2, offer.offerNo, { ...criteria, page: 2 }) + const reference = { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem } + quotePanel.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', `${source.quoteNo}:${source.recordVersion}:${source.priceItem}`); await flushPromises() + const price = formItem(wrapper, scenario.priceBasis === 'PALLET' ? '每托位单价' : '整单价格').findComponent({ name: 'ElInputNumber' }) + expect(price.props('modelValue')).toBe(25.5) + expect(quotePanel.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe(`${source.quoteNo}:${source.recordVersion}:${source.priceItem}`) + expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY') + await price.get('input').setValue('21.75'); await flushPromises() + expect(price.props('modelValue')).toBe(21.75) + expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY') + await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises() + expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) })) + expect(listOfferQuotes).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('saved')).toHaveLength(1) +}) diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue new file mode 100644 index 00000000..d6b758c3 --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/modules/overseas-master-data/components/CapacityInquiryTable.vue b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue new file mode 100644 index 00000000..3d230c7d --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue new file mode 100644 index 00000000..7f748e32 --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/modules/overseas-master-data/components/DriverEditorDialog.vue b/src/modules/overseas-master-data/components/DriverEditorDialog.vue new file mode 100644 index 00000000..34228fb9 --- /dev/null +++ b/src/modules/overseas-master-data/components/DriverEditorDialog.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/modules/overseas-master-data/components/DriverLicensePreview.vue b/src/modules/overseas-master-data/components/DriverLicensePreview.vue new file mode 100644 index 00000000..b27bcfd5 --- /dev/null +++ b/src/modules/overseas-master-data/components/DriverLicensePreview.vue @@ -0,0 +1,47 @@ + + +