merge admin frontend changes

This commit is contained in:
2026-10-08 15:45:26 +08:00
221 changed files with 11843 additions and 423 deletions
+5 -4
View File
@@ -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` |
## 业务、安全与共享装配
Vendored
+28 -4
View File
@@ -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'
}
}
}
}
}
+23
View File
@@ -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
+58
View File
@@ -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
+9
View File
@@ -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"
+3 -1
View File
@@ -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')
+97
View File
@@ -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<typeof loginResponse>) => void) | undefined
const started = new Promise<void>(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()
})
})
+25 -4
View File
@@ -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<null>, ApiEnvelope<null>>(
'/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<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh', { refreshToken: refreshTokenToRevoke },
)
const rotated = unwrapApiResponse<LoginResponse>(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)
}
}
+29 -83
View File
@@ -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')
})
})
+46 -33
View File
@@ -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 },
],
},
]
+12
View File
@@ -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' }),
+14 -5
View File
@@ -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/)
})
@@ -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' })
})
})
@@ -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) {
@@ -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')
@@ -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<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' }
const voidLabels: Record<string, string> = { 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)
</script>
@@ -168,7 +170,7 @@ onMounted(load)
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
@@ -177,6 +179,7 @@ onMounted(load)
<el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column>
<el-table-column prop="businessDate" label="业务日期" width="120" />
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
@@ -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('待关联订舱')
})
})
@@ -0,0 +1,5 @@
import type { ContainerBookingResource } from './types'
export function containerChannelLabel(container: Pick<ContainerBookingResource, 'channelName' | 'oceanBookingId'>): string {
return container.channelName || (container.oceanBookingId ? '—' : '待关联订舱')
}
@@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ p
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, PalletLabelPrintDialog: { template: '<div />' }, 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: '<div />' } }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
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()
@@ -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('<el-tabs v-model="activeTab"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
@@ -11,7 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
@@ -24,6 +24,7 @@ import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.v
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
import {
cancelContainerException,
completeContainerClearance,
@@ -542,12 +543,13 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog dense v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ detail.channelName || '待关联订舱' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ containerChannelLabel(detail) }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<ContainerPortSchedulePanel :container="detail" @saved="load" />
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
@@ -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<string>(), 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: '<div data-testid="workbench">{{ workbench }}</div>' } }))
function render(stage: 'DOMESTIC' | 'OVERSEAS' = 'DOMESTIC') {
return mount(ContainerWorkbenchView, { props: { stage }, global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
}
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)
})
})
@@ -0,0 +1,24 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ContainersView from './ContainersView.vue'
const props = defineProps<{ stage: 'DOMESTIC' | 'OVERSEAS' }>()
const access = useAccessPreviewStore()
const router = useRouter()
const canDomestic = computed(() => access.can('platform:firstleg:container:list'))
const canOverseas = computed(() => access.can('platform:firstleg:terminal-drayage:list'))
const current = computed(() => props.stage === 'DOMESTIC' && !canDomestic.value && canOverseas.value ? 'OVERSEAS' : props.stage)
</script>
<template>
<div class="admin-density">
<nav class="density-status-tabs" aria-label="货柜业务阶段">
<el-button v-if="canDomestic" :class="{ 'is-active': current === 'DOMESTIC' }" :aria-current="current === 'DOMESTIC' ? 'page' : undefined" @click="router.push({ name: 'domestic-container-workbench' })">📦 国内装柜</el-button>
<el-button v-if="canOverseas" :class="{ 'is-active': current === 'OVERSEAS' }" :aria-current="current === 'OVERSEAS' ? 'page' : undefined" @click="router.push({ name: 'overseas-container-workbench' })">🚢 海外清关提柜</el-button>
</nav>
<ContainersView v-if="canDomestic || canOverseas" :key="current" :workbench="current" />
</div>
</template>
@@ -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<ContainerBookingResource, 'cabi
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
const router = useRouter()
const route = useRoute()
const access = useAccessPreviewStore()
const rows = ref<ContainerWorkbenchRow[]>([])
const customers = ref<PlatformTenantResource[]>([])
@@ -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<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
const filters = ref<Filters>({ customerId: '', keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detail = ref<ContainerBookingResource | null>(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()]) })
<template #toolbar-start><h1>{{ isDomestic ? '货柜列表' : '清关提柜' }}</h1></template>
<template #filters>
<div class="container-query-panel integrated-query-group">
<div class="container-scope-tabs density-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
<div class="container-scope-tabs density-status-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
</div>
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</div>
@@ -822,7 +824,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
</template>
<style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; scrollbar-width: thin; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@@ -1,5 +1,5 @@
<script setup lang="ts">
import ContainersView from './ContainersView.vue'
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
</script>
<template><ContainersView workbench="DOMESTIC" /></template>
<template><ContainerWorkbenchView stage="DOMESTIC" /></template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import ContainersView from './ContainersView.vue'
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
</script>
<template><ContainersView workbench="OVERSEAS" /></template>
<template><ContainerWorkbenchView stage="OVERSEAS" /></template>
+1 -1
View File
@@ -5,7 +5,7 @@ export const containerLoadingRoutes = [
path: 'container-loading/domestic',
name: 'domestic-container-workbench',
component: () => import('./pages/DomesticContainersView.vue'),
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内装柜、海外清关提柜与到仓', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
},
{
path: 'container-loading/domestic/:containerId',
+1
View File
@@ -16,6 +16,7 @@ export interface ContainerBookingResource {
channelCode: string | null
channelName: string | null
oceanBookingId?: string | null
currentPortScheduleId?: string | null
oceanBookingRequirementId?: string | null
customerId?: string | null
customerName?: string | null
@@ -1,7 +1,10 @@
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardView from './DashboardView.vue'
import { getTodayNewOrderCount } from '../api/dashboard'
import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard'
const access = vi.hoisted(() => ({ permissions: new Set<string>() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
vi.mock('../api/dashboard', () => ({
getTodayNewOrderCount: vi.fn().mockResolvedValue(12),
@@ -18,7 +21,28 @@ vi.mock('../api/dashboard', () => ({
enableAutoUnmount(afterEach)
describe('DashboardView', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
access.permissions = new Set(['platform:order:list', 'platform:customer:list', 'platform:firstleg:container:list', 'platform:firstleg:customs:list'])
})
it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => {
access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel'])
const wrapper = mount(DashboardView); await flushPromises()
expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0)
expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务')
expect(wrapper.text()).not.toContain('指标加载失败'); expect(wrapper.text()).not.toContain('刷新数据')
})
it('requests only granted metric sources and requires both container and customs permissions', async () => {
access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list'])
const wrapper = mount(DashboardView); await flushPromises()
expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户'])
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises()
expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
})
it('renders only the four live metrics', async () => {
const wrapper = mount(DashboardView)
+20 -13
View File
@@ -7,6 +7,7 @@ import {
RefreshCw,
} from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import {
getActiveCustomerCount,
getDashboardOperationalMetrics,
@@ -22,6 +23,11 @@ const todayNewOrderCount = ref<number | null>(null)
const activeCustomerCount = ref<number | null>(null)
const operationalMetrics = ref<DashboardOperationalMetrics | null>(null)
const metricsUpdatedAt = ref<string | null>(null)
const access = useAccessPreviewStore()
const canReadOrders = computed(() => access.can('platform:order:list'))
const canReadCustomers = computed(() => access.can('platform:customer:list'))
const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list'))
const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value)
function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) {
if (metricsLoading.value) return '…'
@@ -30,17 +36,18 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
}
const metrics = computed(() => [
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value },
{
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
note: operationalMetrics.value
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
: '已开船且尚未到仓',
tone: 'green',
visible: canReadOperations.value,
},
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
])
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', visible: canReadOperations.value },
].filter(metric => metric.visible))
async function toggleFullscreen() {
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
@@ -55,17 +62,17 @@ async function loadMetrics() {
metricsLoading.value = true
metricsError.value = false
const [orders, customers, operations] = await Promise.allSettled([
getTodayNewOrderCount(),
getActiveCustomerCount(),
getDashboardOperationalMetrics(),
canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null),
canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null),
canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null),
])
todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null
activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null
operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null
metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected')
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf
? operations.value.asOf
: dayjs().format('YYYY-MM-DDTHH:mm:ss')
: hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null
metricsLoading.value = false
}
@@ -80,10 +87,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<section ref="dashboardRef" class="dashboard-view">
<AdminPage as="div" spacing="normal">
<section class="dashboard-context" aria-label="数据看板说明">
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
<div class="dashboard-head-actions">
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<RefreshCw :size="16" aria-hidden="true" />
刷新数据
</button>
@@ -95,7 +102,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
</div>
</section>
<div class="dashboard-metrics" aria-label="核心业务指标">
<div v-if="hasMetrics" class="dashboard-metrics" aria-label="核心业务指标">
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
<span class="dashboard-metric-mark" aria-hidden="true" />
<p>{{ metric.label }}</p>
@@ -8,7 +8,7 @@ describe('DomesticContainersView', () => {
it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") })
it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') })
it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') })
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain(".status-tab.is-active::after{background:var(--cc-primary)}"); expect(source).not.toContain('<el-tabs') })
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain('class="status-tabs density-status-tabs"'); expect(source).not.toContain('<el-tabs') })
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
it('keeps all four inventory facts in the approved compact summary', () => {
@@ -76,10 +76,10 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><h1>整柜业务</h1></template>
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid var(--admin-table-border, #e5e7eb);background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;display:inline-flex;align-items:center;gap:var(--admin-space-2, 6px);min-height:var(--admin-tab-height, 38px);padding:0 var(--admin-space-6, 16px);border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:var(--admin-font-body, 13px);cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
@@ -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 () => {
@@ -184,12 +184,17 @@ export function startPlatformFirstLegOrder(tenantId: string, orderId: string) {
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/start`, undefined, { tenantId })
}
export function completePlatformFirstLegOrder(tenantId: string, orderId: string) {
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/complete`, undefined, { tenantId })
}
export function cancelPlatformFirstLegOrder(tenantId: string, orderId: string, reason: string) {
return postData<void>(`/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<void>, ApiEnvelope<void>>(
`/platform/v1/orders/first-leg/${orderId}/cancel`, payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
export function updatePlatformFirstLegOrderExpress(
+2 -1
View File
@@ -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
@@ -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<string, string> }))
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: '<div />' } }))
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: '<div data-testid="detail">{{ detail?.summary.loadingListNo }}{{ error?.message }}</div>' } }))
const rows = [{ planId: '1', warehouseId: '11' }, { planId: '2', warehouseId: '22' }]
function render() {
return mount(LoadingListsView, { props: { mode: 'confirmed' }, global: { stubs: {
AdminPage: { template: '<div><slot /></div>' },
ListPageLayout: { template: '<div><slot /></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.planId"><slot name="actions" :row="row" /></div></div>' },
ElButton: { template: '<button><slot /></button>' },
} } })
}
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()
})
})
@@ -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('<el-button v-if="canView" link')
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 110")
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 150")
expect(source).toContain('flex-wrap: wrap')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.row-actions { gap: 4px; }')
@@ -40,6 +40,9 @@ const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const query = reactive<QueryModel>({ 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<DataTableColumn[]>(() => 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(() => {
</template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
</div></template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
@@ -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<string, string> } }))
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: '<main><slot /></main>' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' },
} } })
}
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()
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -37,6 +37,14 @@ async function loadDetail() {
return
}
detail.value = result
loading.value = false
if (route.query.print === '1') {
await nextTick()
if (sequence !== loadSequence) return
window.print()
const { print: _print, ...query } = route.query
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
}
} catch (cause) {
if (sequence !== loadSequence) return
detail.value = null
@@ -51,6 +59,10 @@ function goBack() {
else void router.push({ name: 'loading-lists' })
}
function printDetail() {
window.print()
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => void loadDetail())
</script>
@@ -58,6 +70,7 @@ watch([planId, warehouseId], () => void loadDetail())
<template>
<AdminPage>
<div class="confirmed-loading-detail-page">
<el-button v-if="detail" class="print-action" :disabled="loading" @click="printDetail">打印</el-button>
<LoadingListDetailDrawer
mode="confirmed"
:loading="loading"
@@ -73,5 +86,6 @@ watch([planId, warehouseId], () => void loadDetail())
<style scoped>
.confirmed-loading-detail-page { display: grid; min-width: 0; max-width: 1280px; gap: 16px; }
@media print { .print-action { display: none; } }
@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }
</style>
+3 -1
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
import { loadingListRoutes } from './routes'
describe('loading list routes', () => {
it('keeps completed loading lists inside domestic container detail', () => {
it('registers the confirmed list with its own read permission', () => {
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
['loading-lists', 'platform:firstleg:loading-list:list'],
['loading-plans', 'platform:firstleg:preload:list'],
['loading-plans/:planId', 'platform:firstleg:preload:list'],
])
+12
View File
@@ -1,6 +1,18 @@
import type { RouteRecordRaw } from 'vue-router'
export const loadingListRoutes = [
{
path: 'loading-lists/:planId',
name: 'loading-list-detail',
component: () => import('./pages/ConfirmedLoadingListDetailView.vue'),
meta: { title: '装柜清单详情', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
},
{
path: 'loading-lists',
name: 'loading-lists',
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
meta: { title: '装柜清单', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
},
{
path: 'loading-plans',
name: 'loading-plans',
@@ -1,5 +1,18 @@
# 海外业务原型核对矩阵
## 池来源本土订单承运安排
依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。
| 页面 | 操作 | 确认承载与实现 |
| --- | --- | --- |
| 本土卡车 / 本土散板列表 | 池来源承运安排 | 1080px居中只读摘要,使用列表已有fleetArrangement,打开不增加请求;不显示旧编辑动作 |
| 本土卡车 / 本土散板旧编辑深链 | 池来源只读 | 保持独立路由,读取原订单详情后仅展示真实安排与只读原因,不提交旧手工流程 |
| 承运安排 | 关联单据 | 有目标权限才开放车次/排车跳转,否则仅显示业务号;未知签收数量显示“—” |
| Client本土订单 | 列表摘要 / 原独立详情 | 同一客户订单响应展示承运安排,客户不访问运力池,不提供状态写入 |
独立来源订单保持原承载。本轮视觉按1920×1080内置浏览器验收,自动检查与实际页面证据分别报告。
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
@@ -107,4 +107,12 @@ describe('platform overseas business API adapter', () => {
expect(overseas.post).not.toHaveBeenCalled()
expect(overseas.put).not.toHaveBeenCalled()
})
it('preserves unknown pool pallet service and container type in list and detail responses', async () => {
const summary = { ...looseboardSummary, palletRequired: null, containerType: null }
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, containerType: null })
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, containerType: null })
expect(overseas.get).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,62 @@
import ElementPlus from 'element-plus'
import { mount, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { compileStyle, parse } from 'vue/compiler-sfc'
import FleetArrangementSummary from './FleetArrangementSummary.vue'
import type { FleetArrangement } from '../fleetArrangement'
const state = vi.hoisted(() => ({ writableLinks: true, push: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.writableLinks }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }) }))
enableAutoUnmount(afterEach)
beforeEach(() => { state.writableLinks = true; state.push.mockClear() })
const arrangement: FleetArrangement = {
sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: 'LB-ONT-20261007-309123', bookingNo: 'FB-2107769452587171841', tripNo: 'TR-76DB64B4457C4206',
plannedDepartureAt: '2026-10-07T13:46:07Z', originWarehouseName: '洛杉矶仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: '交货说明完整保留', fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null,
agreedFreightAmount: 30, currency: 'CNY', incomingNo: 'WT-ONT-20261007-12345678901234567890', dispatchNo: 'DP-ONT-20261007-12345678901234567890', deliveryNo: 'DL-ONT-20261007-12345678901234567890',
signedPackages: 0, unsignedPackages: null, returnedPackages: 0, exceptionSummary: null, readOnlyReason: '此订单由拼车承运实际履约推进',
}
function render() { return mount(FleetArrangementSummary, { props: { arrangement }, global: { plugins: [ElementPlus] } }) }
it('retains all long business numbers, labels and authorized navigation', async () => {
const wrapper = render()
const rows = wrapper.findAll('.fleet-arrangement-fields > div')
expect(rows).toHaveLength(14)
for (const [label, value] of [['订单号', arrangement.businessOrderNo], ['外接预订号', arrangement.bookingNo], ['车次号', arrangement.tripNo], ['来货单号', arrangement.incomingNo], ['排车单号', arrangement.dispatchNo], ['派送单号', arrangement.deliveryNo]]) {
expect(rows.find(row => row.get('dt').text() === label)!.get('dd').text()).toBe(value)
}
expect(rows.find(row => row.get('dt').text() === '已签 / 未签 / 退回箱数')!.get('dd').text()).toBe('0 / — / 0')
const links = wrapper.findAll('button.fleet-arrangement-reference')
expect(links.map(link => link.text())).toEqual([arrangement.tripNo, arrangement.dispatchNo])
await links[0]!.trigger('click'); await links[1]!.trigger('click')
expect(state.push).toHaveBeenNthCalledWith(1, { name: 'overseas-master-data-trip-detail', params: { tripNo: arrangement.tripNo } })
expect(state.push).toHaveBeenNthCalledWith(2, { name: 'overseas-dispatch-detail', params: { dispatchNo: arrangement.dispatchNo } })
})
it('keeps full reference text without links when target permissions are absent', () => {
state.writableLinks = false
const wrapper = render()
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.text()).toContain(arrangement.tripNo); expect(wrapper.text()).toContain(arrangement.dispatchNo)
})
it('uses a scoped two-column layout and wrap-capable reference controls rather than truncation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/components/FleetArrangementSummary.vue'), 'utf8')
const style = parse(source).descriptor.styles[0]!
expect(style.scoped).toBe(true)
const compiled = compileStyle({ source: style.content, filename: 'FleetArrangementSummary.vue', id: 'data-v-arrangement', scoped: true })
expect(compiled.errors).toEqual([])
const rules = new Map<string, Record<string, string>>()
compiled.rawResult!.root.walkRules(rule => {
const declarations: Record<string, string> = {}
rule.walkDecls(declaration => { declarations[declaration.prop] = declaration.value })
rules.set(rule.selector, declarations)
})
const grid = [...rules].find(([selector]) => selector.includes('.fleet-arrangement-fields.density-fields'))!
expect(grid[0]).toContain('[data-v-arrangement]')
expect(grid[1]).toMatchObject({ 'grid-template-columns': 'repeat(2, minmax(0, 1fr))', '--admin-field-label-width': '160px' })
const button = [...rules].find(([selector]) => selector.endsWith('.fleet-arrangement-reference[data-v-arrangement]'))!
expect(button[1]).toMatchObject({ 'max-width': '100%', 'min-width': '0', height: 'auto', 'white-space': 'normal', 'text-align': 'left' })
const text = [...rules].find(([selector]) => selector.endsWith(' span'))!
expect(text[1]).toMatchObject({ 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0' })
expect(style.content).not.toMatch(/text-overflow:\s*ellipsis|overflow:\s*hidden|white-space:\s*nowrap/)
})
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useRouter } from 'vue-router'
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
defineProps<{ arrangement: FleetArrangement }>()
const access = useAccessPreviewStore(), router = useRouter()
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
</script>
<template>
<section aria-label="承运安排">
<p>{{ arrangement.readOnlyReason }}</p>
<dl class="density-fields fleet-arrangement-fields">
<div><dt>订单号</dt><dd>{{ arrangement.businessOrderNo }}</dd></div>
<div><dt>外接预订号</dt><dd>{{ arrangement.bookingNo }}</dd></div>
<div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" class="fleet-arrangement-reference" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: arrangement.tripNo } })">{{ arrangement.tripNo }}</el-button><span v-else>{{ arrangement.tripNo }}</span></dd></div>
<div><dt>计划发车</dt><dd>{{ time(arrangement.plannedDepartureAt) }}</dd></div>
<div><dt>起运仓</dt><dd>{{ arrangement.originWarehouseName || '—' }}</dd></div>
<div><dt>目的地</dt><dd>{{ arrangement.destinationLabel || '—' }}</dd></div>
<div><dt>交货方式</dt><dd>{{ arrangement.deliveryMode === 'SELF_DELIVERY' ? '客户自送我仓' : '线下提货回我仓' }}</dd></div>
<div><dt>履约阶段</dt><dd>{{ poolOrderStatusLabels[arrangement.fulfillmentStage] || '待核对履约进度' }}</dd></div>
<div><dt>阶段时间</dt><dd>{{ time(arrangement.stageOccurredAt) }}</dd></div>
<div><dt>约定运费(非实收)</dt><dd>{{ arrangement.agreedFreightAmount == null ? '—' : `${Number(arrangement.agreedFreightAmount).toFixed(2)} ${arrangement.currency || ''}` }}</dd></div>
<div><dt>来货单号</dt><dd>{{ arrangement.incomingNo || '—' }}</dd></div>
<div><dt>排车单号</dt><dd><el-button v-if="arrangement.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" class="fleet-arrangement-reference" link type="primary" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: arrangement.dispatchNo } })">{{ arrangement.dispatchNo }}</el-button><span v-else>{{ arrangement.dispatchNo || '—' }}</span></dd></div>
<div><dt>派送单号</dt><dd>{{ arrangement.deliveryNo || '—' }}</dd></div>
<div><dt>已签 / 未签 / 退回箱数</dt><dd>{{ arrangement.signedPackages ?? '—' }} / {{ arrangement.unsignedPackages ?? '—' }} / {{ arrangement.returnedPackages ?? '—' }}</dd></div>
</dl>
<p v-if="arrangement.deliveryInstructions">交货说明:{{ arrangement.deliveryInstructions }}</p>
<p v-if="arrangement.exceptionSummary" role="status">履约说明:{{ arrangement.exceptionSummary }}</p>
</section>
</template>
<style scoped>
.fleet-arrangement-fields.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); --admin-field-label-width: 160px; }
.fleet-arrangement-fields dt { overflow-wrap: anywhere; }
.fleet-arrangement-fields .fleet-arrangement-reference { max-width: 100%; min-width: 0; height: auto; justify-content: flex-start; white-space: normal; text-align: left; line-height: inherit; }
.fleet-arrangement-reference :deep(span) { display: block; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,29 @@
export interface FleetArrangement {
sourceType: 'FLEET_CAPACITY_OFFER'
businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
businessOrderNo: string
bookingNo: string
tripNo: string
plannedDepartureAt: string | null
originWarehouseName: string
destinationLabel: string
deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'
deliveryInstructions: string | null
fulfillmentStage: string
stageOccurredAt: string | null
agreedFreightAmount: number | null
currency: string | null
incomingNo: string | null
dispatchNo: string | null
deliveryNo: string | null
signedPackages: number | null
unsignedPackages: number | null
returnedPackages: number | null
exceptionSummary: string | null
readOnlyReason: string
}
export const poolOrderStatusLabels: Record<string, string> = {
WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消',
}
@@ -9,6 +9,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { FleetArrangement } from '../fleetArrangement'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
import {
createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
@@ -44,10 +45,11 @@ const purchase: OverseasPurchaseDetail = {
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
}
const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
@@ -65,6 +67,49 @@ describe('overseas business mounted pages', () => {
})
it('opens pool arrangements from the list without another request or exposing edit actions', async () => {
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
expect(wrapper.findAll('.el-table__body-wrapper button').some(b => b.text() === '编辑')).toBe(false)
await wrapper.findAll('.el-table__body-wrapper button').find(b => b.text() === '承运安排')!.trigger('click'); await flushPromises()
expect(document.body.textContent).toContain('池来源订单按实际运输履约推进')
expect(document.body.textContent).toContain('FB-TEST'); expect(document.body.textContent).toContain('— / — / —')
expect(listUsTruckOrders).toHaveBeenCalledOnce(); expect(getUsTruckOrder).not.toHaveBeenCalled()
const dialog = wrapper.findComponent(AppDialog)
await dialog.findAll('.app-dialog__footer button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
})
it('keeps a pool order read-only even when an old edit deep link is used', async () => {
routing.route.params = { orderId: 'TR-200' }; routing.route.query = { customerId: truck.customerId }
vi.mocked(getUsTruckOrder).mockResolvedValue({ ...truckDetail, fleetArrangement: arrangement, status: 'WAITING_INBOUND' })
const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises()
expect(wrapper.text()).toContain('池来源订单按实际运输履约推进')
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
})
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
})
it('shows an unknown pool container type as a dash without inventing 40HQ', async () => {
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, containerType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[7]!.text()).toBe('—')
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
})
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请选择是否打盘')
})
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
const wrapper = mountPage(PurchaseOrderDetailView)
@@ -17,6 +17,8 @@ import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domai
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import type { FleetArrangement } from '../fleetArrangement'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
@@ -37,6 +39,7 @@ interface TransportFormModel {
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
const fleetArrangement = ref<FleetArrangement | null>(null)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
@@ -54,9 +57,9 @@ const rules: FormRules<TransportFormModel> = {
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
const editableStatus = computed(() => !editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))
const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')))
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
function optional(value: string | null) { return value?.trim() || null }
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
@@ -68,16 +71,18 @@ async function loadCustomers() {
finally { customersLoading.value = false }
}
async function load() {
Object.assign(form, initial()); loadError.value = null; submitError.value = null
Object.assign(form, initial()); loadError.value = null; submitError.value = null; fleetArrangement.value = null
if (!editing.value) { captureBaseline(); return }
if (!routeCustomerId.value) { loadError.value = { message: `缺少客户标识,请从${domainLabel.value}列表重新进入` }; captureBaseline(); return }
loading.value = true
try {
if (isLooseboard.value) {
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
} else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
}
captureBaseline()
@@ -86,6 +91,7 @@ async function load() {
async function save() {
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
if (isLooseboard.value && form.palletRequired !== 'YES' && form.palletRequired !== 'NO') { submitError.value = { message: '请选择是否打盘' }; return }
if (!await formRef.value?.validate().catch(() => false)) return
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
saving.value = true; submitError.value = null
@@ -110,6 +116,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
@@ -9,6 +9,9 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
@@ -23,9 +26,11 @@ const createPermission = computed(() => isLooseboard.value ? overseasBusinessPer
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit)
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
const fields = computed<QueryField[]>(() => [
@@ -37,7 +42,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90, formatter: (row: LocalTransportOrder) => row.containerType || '—' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
@@ -45,7 +50,7 @@ const truckColumns: DataTableColumn[] = [
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : '否' },
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : row.palletRequired === 'NO' ? '否' : '—' },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
@@ -69,7 +74,7 @@ async function load() {
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW' }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
</script>
@@ -85,10 +90,10 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog></AdminPage>
</template>
<style scoped>.table-panel { overflow:hidden; }</style>
+4 -2
View File
@@ -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
@@ -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核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
@@ -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 保持字符串透传。
@@ -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%视觉及请求量待总控验收 |
@@ -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<typeof import('@/shared/api/http')>()), 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 = <T,>(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 }] })
})
@@ -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<T>(value: OfferPage<T>, normalize: (row: T) => T): OfferPage<T> { 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<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
async function put<T>(url: string, body: object) { return unwrapApiResponse(await http.put<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
export async function listCapacityOffers(query: OfferQuery) { return page(await get<OfferPage<CapacityOffer>>(base, query), normalizeOffer) }
export async function getCapacityOffer(offerNo: string): Promise<CapacityOfferDetail> { const value = await get<CapacityOfferDetail>(path(offerNo)); return { offer: normalizeOffer(value.offer), inquiries: page(value.inquiries, normalizeInquiry) } }
export async function listCapacityInquiries(query: InquiryQuery) { return page(await get<OfferPage<CapacityInquiry>>(`${base}/inquiries`, query), normalizeInquiry) }
export async function getCapacityInquiry(bookingNo: string) { return normalizeInquiry(await get<CapacityInquiry>(inquiryPath(bookingNo))) }
export async function saveCapacityOffer(offerNo: string | null, body: OfferWrite) { return offerResult(await (offerNo ? put<OfferResult>(path(offerNo), body) : post<OfferResult>(base, body))) }
export async function commandCapacityOffer(offerNo: string, action: 'publish' | 'pause' | 'resume' | 'close', body: OfferCommand) { return offerResult(await post<OfferResult>(`${path(offerNo)}/${action}`, body)) }
export async function saveCapacityInquiry(offerNo: string, bookingNo: string | null, body: InquiryWrite) { return inquiryResult(await (bookingNo ? put<InquiryResult>(inquiryPath(bookingNo), body) : post<InquiryResult>(`${path(offerNo)}/inquiries`, body))) }
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/${action}`, body)) }
export const listOfferCustomers = (keyword?: string) => get<OfferCustomerOption[]>(`${base}/customer-options`, { keyword })
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get<WireQuotePage>(`${path(offerNo)}/quote-candidates`, query)) }
@@ -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<typeof import('@/shared/api/http')>()), 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('精确展示')
})
@@ -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<CompanyCost, 'recordVersion' | 'paidAmount' | 'outstandingAmount' | 'source'> & {
recordVersion: Numeric; paidAmount: Numeric; outstandingAmount?: Numeric | null
source: Omit<CompanyCost['source'], 'sourceVersion' | 'supplierId' | 'originalAmount' | 'exchangeRate' | 'amount'> & {
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<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
function page<T, R>(value: PageWire<T>, convert: (value: T) => R): PageResult<R> {
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<CompanyCost> {
return cost(unwrapApiResponse(await http.get<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(base(no))), no)
}
export async function commandCompanyCost(no: string, action: 'approve' | 'void', key: string, request: CompanyCostCommand): Promise<CompanyCost> {
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${base(no)}/${action}`, request, { headers: { 'Idempotency-Key': key } })), no)
}
export async function payCompanyCost(no: string, key: string, request: CompanyCostPayment): Promise<CompanyCost> {
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${base(no)}/payments`, request, { headers: { 'Idempotency-Key': key } })), no)
}
export async function listCompanyCostHistory(no: string, currentPage = 1): Promise<PageResult<CompanyCostHistory>> {
type HistoryWire = Omit<CompanyCostHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: CostWire }
const value = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${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<PageResult<CompanyCostPaid>> {
type PaidWire = Omit<CompanyCostPaid, 'amount' | 'voucherFileIds'> & { amount: Numeric; voucherFileIds: Numeric[] }
const value = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<PaidWire>>, ApiEnvelope<PageWire<PaidWire>>>(`${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<CompanyCostVoucher> {
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<Wire>, ApiEnvelope<Wire>>(`${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<void> {
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<unknown>
throw new ApiResponseError(error.message || '付款凭证读取失败', error.code, status, error.data, error.requestId, error.traceId)
}
}
export async function downloadCompanyCostVoucher(no: string, fileId: string): Promise<Blob> {
try {
const value = await http.get<Blob, Blob>(`${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
}
}
@@ -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<typeof import('@/shared/api/http')>()), 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(['<html>'], { 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('无效的分页或版本信息')
})
})
@@ -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<DriverDetail, 'recordVersion'> & { recordVersion: Numeric }
type HistoryWire = Omit<DriverHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: DriverWire }
type PageWire<T> = { 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<T, W>(value: PageWire<W>, normalize: (record: W) => T): PageResult<T> {
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<DriverWire> & { summary: { [K in keyof DriverSummary]: Numeric } }
export async function listDrivers(query: DriverQuery): Promise<PageResult<DriverDetail> & { summary: DriverSummary }> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<DriverPageWire>, ApiEnvelope<DriverPageWire>>(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<DriverWarehouseOption[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<DriverWarehouseOption[]>, ApiEnvelope<DriverWarehouseOption[]>>(`${base}/warehouse-options`, { params: { region } }))
}
export async function getDriver(id: string, expectedEnd?: string): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.get<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { params: { expectedEnd } })))
}
export async function getDriverQualification(id: string, expectedEnd: string): Promise<DriverQualification> {
return unwrapApiResponse(await http.get<ApiEnvelope<DriverQualification>, ApiEnvelope<DriverQualification>>(`${path(id)}/qualification`, { params: { expectedEnd } }))
}
export async function listDriverHistory(id: string, currentPage = 1, size = 20): Promise<PageResult<DriverHistory>> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${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<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(base, details)))
}
export async function updateDriver(id: string, recordVersion: number, details: DriverDraft): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.put<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { recordVersion, details })))
}
export async function changeDriverStatus(id: string, recordVersion: number, active: boolean, reason: string): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.patch<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/status`, { recordVersion, active, reason })))
}
export async function reviewDriver(id: string, recordVersion: number, decision: 'APPROVED' | 'REJECTED', reason: string | null): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/review`, { recordVersion, decision, reason })))
}
export async function submitDriver(id: string, recordVersion: number): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/submit`, { recordVersion })))
}
export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise<DriverDetail> {
const form = new FormData()
form.append('recordVersion', String(recordVersion))
form.append('file', file)
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/license`, form)))
}
async function checkDownloadError(payload: unknown, status: number): Promise<void> {
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<ApiEnvelope<unknown>>
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<Blob> {
try {
const blob = await http.get<Blob, Blob>(`${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
}
}
@@ -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<typeof import('@/shared/api/http')>()), 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' })
})
@@ -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<FleetQuoteSourceSnapshot, 'recordVersion' | 'distanceMiles' | 'quotedUnitPrice'> & {
recordVersion: Numeric; distanceMiles: Numeric; quotedUnitPrice: Numeric
}
export type WireQuotePage = Omit<PageResult<WireSnapshot>, '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<FleetQuoteSourceSnapshot> => 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<PageResult<FleetQuoteSourceSnapshot>> {
const response = unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>('/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 }
}
@@ -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<typeof import('@/shared/api/http')>()), 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)
})
@@ -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<FleetQuote, 'details' | 'recordVersion'> & { recordVersion: Numeric; details: Omit<FleetQuoteDraft, 'distanceMiles' | 'selfPickupPrice' | 'fullDeliveryPrice' | 'looseDeliveryPrice'> & { 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<T>(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult<T> {
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<PageResult<WireQuote>>, ApiEnvelope<PageResult<WireQuote>>>(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<WireQuote>, ApiEnvelope<WireQuote>>(base, { idempotencyKey, details }))) }
export async function updateFleetQuote(value: FleetQuote, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.put<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(path(value.quoteNo), { recordVersion: value.recordVersion, details }))) }
export async function setFleetQuoteStatus(value: FleetQuote, status: string) { return quote(unwrapApiResponse(await http.patch<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(`${path(value.quoteNo)}/status`, { recordVersion: value.recordVersion, status }))) }
export async function deleteFleetQuote(value: FleetQuote) { return quote(unwrapApiResponse(await http.delete<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(path(value.quoteNo), { params: { recordVersion: value.recordVersion } }))) }
export async function importFleetQuotes(idempotencyKey: string, rows: FleetQuoteDraft[]) { return unwrapApiResponse(await http.post<ApiEnvelope<{ importedRows: number }>, 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 }[]>, 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<PageResult<{ id: string; name: string }>>, ApiEnvelope<PageResult<{ id: string; name: string }>>>('/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<Blob> {
function check(bytes: ArrayBuffer, status: number) {
const text = new TextDecoder().decode(bytes)
if (!/^\s*[{[]/.test(text)) return
let error: ApiEnvelope<unknown>
try { error = JSON.parse(text) as ApiEnvelope<unknown> } 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<ArrayBuffer, ArrayBuffer>(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) }
@@ -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' }))
@@ -17,7 +17,7 @@ type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouse
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null }
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
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
@@ -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<typeof import('@/shared/api/http')>()), 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 } })
})
@@ -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<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { 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<WireQuotePage>, ApiEnvelope<WireQuotePage>>(`${path(tripNo)}/quote-candidates`, { params: criteria })))
}
export async function listFleetCustomers(keyword?: string): Promise<FleetCustomerOption[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<FleetCustomerOption[]>, ApiEnvelope<FleetCustomerOption[]>>(`${base}/customer-options`, { params: { keyword } }))
}
export async function listTripBookings(tripNo: string): Promise<TripBookingList> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>, 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<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
}
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
}
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${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<TripIncomingPreview> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>, 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<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${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)
@@ -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<typeof import('@/shared/api/http')>()), 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(/无效金额|精确展示范围/)
})
@@ -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<TripCost>; 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<TripCost, 'recordVersion' | 'details'> & { recordVersion: Numeric; details: Omit<TripCostDraft, 'amount' | 'remark'> & { amount: Numeric; remark?: string | null } }
type SummaryWire = Omit<CostSummary, 'departureVersion' | 'totals'> & { departureVersion?: Numeric | null; totals: Array<Omit<CostTotal, 'directCost' | 'ownCost' | 'externalCost' | 'allocations'> & {
directCost: Numeric; ownCost?: Numeric | null; externalCost?: Numeric | null; allocations: Array<Omit<CostAllocation, 'amount' | 'palletSpaces'> & { amount: Numeric; palletSpaces: Numeric }>
}> }
type PageWire<T> = { 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<T, R>(value: PageWire<T>, transform: (row: T) => R): PageResult<R> {
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<TripCosts> {
type Wire = { page: PageWire<CostWire>; summary: SummaryWire }
const result = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(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<TripCost> {
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(path(tripNo), { idempotencyKey, details })))
}
export async function updateTripCost(tripNo: string, record: Pick<TripCost, 'costNo' | 'recordVersion'>, details: TripCostDraft, reason?: string): Promise<TripCost> {
return cost(unwrapApiResponse(await http.put<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${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<PageResult<TripCostHistory>> {
type HistoryWire = Omit<TripCostHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: { cost: CostWire; summary: SummaryWire } }
type Wire = PageWire<HistoryWire>
const result = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${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) } }))
}
@@ -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<typeof import('@/shared/api/http')>()), 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()
})
@@ -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<PageResult<HandoverRecord>> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: HandoverRecord[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }>, 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<HandoverPreview> {
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverPreview>, ApiEnvelope<HandoverPreview>>(`${path(tripNo)}/preview`, selection))
}
export async function confirmTripHandover(tripNo: string, command: HandoverCommand): Promise<HandoverRecord> {
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverRecord>, ApiEnvelope<HandoverRecord>>(path(tripNo), command))
}
export async function releaseHandoverResource(tripNo: string, handoverNo: string, command: HandoverRelease): Promise<HandoverRecord> {
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverRecord>, ApiEnvelope<HandoverRecord>>(`${path(tripNo)}/${encodeURIComponent(handoverNo)}/resources/release`, command))
}
@@ -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<typeof import('@/shared/api/http')>()), 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' })
})
@@ -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<CostTotal, 'currency' | 'directCost' | 'ownCost' | 'externalCost'>
export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] }
export type OperationsRowCosts = Pick<CostSummary, 'allocationBlockReason'> & { 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<TripOperationsMeasured, 'evidenceFiles'> & { 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<TripOperationsRow>; summary: TripOperationsSummary; charts: TripOperationsCharts }
type WireNumbers<T> = { [K in keyof T]: T[K] extends number ? number | string : T[K] }
type WireMoney = { currency: string; amount: string | number }
type WireEvidence = Omit<TripOperationsEvidence, 'sizeBytes'> & { 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<WireNumbers<TripOperationsRow>, '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<WireNumbers<TripOperationsSummary>, '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<TripOperationsSeries, 'points'> & { 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<TripOperationsResult> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; 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<TripOperationsRecord, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
type WireHistory = Omit<TripOperationsRecordHistory, 'recordVersion' | 'details'> & { 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<WireRecord>, ApiEnvelope<WireRecord>>(recordPath(tripNo)))) }
export async function saveTripOperationsRecord(tripNo: string, request: TripOperationsRecordSave) { return record(unwrapApiResponse(await http.put<ApiEnvelope<WireRecord>, ApiEnvelope<WireRecord>>(recordPath(tripNo), request))) }
export async function listTripOperationsRecordHistory(tripNo: string, page = 1, size = 20) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<WireHistory>>, ApiEnvelope<PageResult<WireHistory>>>(`${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<TripOperationsEvidence> {
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<WireEvidence>, ApiEnvelope<WireEvidence>>(`${recordPath(tripNo)}/files`, body)))
}
export async function downloadTripOperationsEvidence(tripNo: string, fileId: string): Promise<Blob> {
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<unknown>; throw new ApiResponseError(e.message || '凭证读取失败', e.code, status, e.data, e.requestId, e.traceId) }
}
try {
const value = await http.get<Blob, Blob>(`${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 }
}
@@ -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<typeof import('@/shared/api/http')>()), 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('车次不匹配')
})
@@ -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<TripFinanceResult, 'finance'> & { finance: Omit<TripFinanceResult['finance'], 'totals' | 'sources' | 'documents'> & {
totals: { currency: string; receivableAmount: string | number; receivedAmount: string | number | null }[]
sources: (Omit<TripFinanceSource, 'amount'> & { amount: string | number })[]
documents: (Omit<TripFinanceDocument, 'receivedAmount'> & { receivedAmount: string | number | null })[]
} }
export async function getTripOperationsFinance(tripNo: string): Promise<TripFinanceResult> {
const data = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`/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) })),
} }
}
@@ -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<typeof import('@/shared/api/http')>()), 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 = <T>(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)
})
})
@@ -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<FleetTrip, 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & {
recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null
}
type WireReview = Omit<TripReviewState, 'recordVersion'> & { recordVersion: Numeric | null }
type WireBinding = Omit<TripBinding, 'packageCount' | 'review'> & { packageCount: Numeric; review: WireReview }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview }
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
type WireLoad = { [K in keyof TripLoad]: Numeric }
type WireMeasurement = Omit<TripMeasurement, 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'deliveryIndex'> & WireLoad & { deliveryIndex: Numeric }
type WireConfirmed = Omit<TripConfirmedLoad, 'cargo' | 'pallets' | 'segments'> & {
cargo: WireCargo[]; pallets: WireMeasurement[]
segments: { fromIndex: Numeric; toIndex: Numeric; occupied: WireLoad; remaining: WireLoad }[]
}
type WireLoading = Omit<TripLoading, 'recordVersion' | 'cargo' | 'confirmedLoad'> & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null }
type WirePlanning = Omit<TripCapacityPlanning, 'recordVersion' | 'cargo' | 'capacityPlan' | 'availableSegments'> & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] }
type WireOutcome = Omit<TripOutcome, 'packages' | 'signedPackages' | 'returnedPackages' | 'remainingPackages'> & { packages: Numeric; signedPackages: Numeric; returnedPackages: Numeric; remainingPackages: Numeric }
type WireInspection = Omit<TripCompletionInspection, 'recordVersion' | 'outcomes'> & { recordVersion: Numeric; outcomes: WireOutcome[] }
type WireHistory = Omit<TripHistory, 'recordVersion' | 'snapshot'> & {
recordVersion: Numeric
snapshot: { externalBookings: WireBooking[]; capacityPlan: WireConfirmed | null; trip: WireTrip; dispatches: WireBinding[]; confirmedLoad: WireConfirmed | null; loadingReview: WireReview; completion: (Omit<NonNullable<TripHistory['snapshot']['completion']>, '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<T, W>(value: WirePage<W>, map: (row: W) => T): PageResult<T> {
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<PageResult<FleetTrip>> {
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
}
export async function getTrip(tripNo: string): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
}
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(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<TripDetail> {
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${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<TripCapacityPlanning> {
return planning(unwrapApiResponse(await http.get<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`)))
}
export async function confirmTripCapacityPlan(tripNo: string, body: TripLoadConfirmation): Promise<TripCapacityPlanning> {
return planning(unwrapApiResponse(await http.post<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`, body)))
}
export async function getTripLoading(tripNo: string): Promise<TripLoading> {
return loading(unwrapApiResponse(await http.get<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`)))
}
export async function confirmTripLoading(tripNo: string, body: TripLoadConfirmation): Promise<TripLoading> {
return loading(unwrapApiResponse(await http.post<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`, body)))
}
export async function getTripCompletion(tripNo: string): Promise<TripCompletionInspection> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WireInspection>, ApiEnvelope<WireInspection>>(`${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<PageResult<TripHistory>> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireHistory>>, ApiEnvelope<WirePage<WireHistory>>>(`${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) },
} }))
}
@@ -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<typeof import('@/shared/api/http')>()), 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<ApiResponseError>)
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()
})
@@ -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<VehicleCareRecord, 'recordVersion' | 'details' | 'files'> & {
recordVersion: Numeric
details: Pick<VehicleCareDraft, 'recordType' | 'occurredAt'> & Partial<Omit<VehicleCareDraft, 'odometer' | 'lastServiceOdometer' | 'nextServiceOdometer' | 'fuelQuantity' | 'incident'>> & {
odometer?: Numeric | null; lastServiceOdometer?: Numeric | null; nextServiceOdometer?: Numeric | null; fuelQuantity?: Numeric | null
incident?: (Pick<VehicleIncident, 'description' | 'processingStatus'> & Partial<Omit<VehicleIncident, 'reportedCost'>> & { reportedCost?: Numeric | null }) | null
}
files: Array<Omit<VehicleCareRecord['files'][number], 'sizeBytes'> & { 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<VehicleCareFinanceState, string> = {
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<VehicleCareFinanceStatus[]> {
if (!recordNos.length) return []
type Cost = NonNullable<VehicleCareFinanceStatus['finance']>
type StatusWire = Omit<VehicleCareFinanceStatus, 'sourceVersion' | 'submittedRecordVersion' | 'finance'> & {
sourceVersion?: Numeric | null; submittedRecordVersion?: Numeric | null
finance?: (Omit<Cost, 'paidAmount' | 'outstandingAmount' | 'source'> & { paidAmount: Numeric; outstandingAmount?: Numeric | null;
source: Omit<Cost['source'], 'originalAmount' | 'amount' | 'exchangeRate'> & { originalAmount: Numeric; amount: Numeric; exchangeRate: Numeric } }) | null
}
const response = unwrapApiResponse(await http.post<ApiEnvelope<StatusWire[]>, ApiEnvelope<StatusWire[]>>(`${path(vehicleId)}/finance-status`, { recordNos }))
const seen = new Set<string>()
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<VehicleCareFinanceSubmitted> {
type Wire = Omit<VehicleCareFinanceSubmitted, 'sourceVersion' | 'submittedRecordVersion'> & { sourceVersion: Numeric; submittedRecordVersion: Numeric }
const response = unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${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<Array<{ id: string; name: string }>> {
type Supplier = { id: Numeric; name: string; status: string }
const response = unwrapApiResponse(await http.get<ApiEnvelope<{ records: Supplier[] }>, 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<PageResult<VehicleCareRecord>> {
type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire>, ApiEnvelope<PageWire>>(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<VehicleCareRecord> {
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId), { idempotencyKey, details })))
}
export async function getVehicleCare(vehicleId: string, recordNo: string): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo))))
}
export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.put<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo), { recordVersion, reason, details })))
}
export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise<VehicleCareRecord> {
const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file)
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/files`, form)))
}
async function downloadError(value: unknown, status: number): Promise<void> {
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<unknown>
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<Blob> {
try {
const blob = await http.get<Blob, Blob>(`${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
}
}
@@ -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('分页数值')
})
@@ -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<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
type WireChange = Omit<VehicleAssignmentChange, 'recordVersion'> & { 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<T, W>(value: WirePage<W>, normalize: (row: W) => T): PageResult<T> {
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<WirePage<VehicleDelivery>>, ApiEnvelope<WirePage<VehicleDelivery>>>(`${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<WirePage<WireChange>>, ApiEnvelope<WirePage<WireChange>>>(`${base(vehicleId)}/assignment-history`, { params: { page: currentPage, size } }))
return page(result, row => ({ ...row, recordVersion: String(row.recordVersion) }))
}
@@ -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<TripBookingCommand, 'reason'> { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null }
export interface TripIncomingAccept extends Omit<TripBookingCommand, 'reason'> { receiptHash: string; pallets: TripPalletSpaces[]; unitPrice: number }
export interface TripBookingBind extends Omit<TripBookingCommand, 'reason'> { dispatchNo: string }
@@ -0,0 +1,41 @@
import type { TripBookingCargo } from './bookingTypes'
import type { FleetQuoteReference, FleetQuoteSourceSnapshot } from './api/fleetQuoteCandidates'
export interface OfferPage<T> { 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<CapacityInquiry> }
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³` : '待调度核对'
@@ -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<HTMLInputElement>(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: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, 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: '<RouterView v-slot="{ Component, route }"><KeepAlive><component :is="Component" :key="route.name" /></KeepAlive></RouterView>' })
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<HTMLInputElement>('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<HTMLInputElement>('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')
})
@@ -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<string>() }))
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)
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, toRaw, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
const lock = computed(() => !writable.value || busy.value || !!pending.value)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
let generation = 0, customerSequence = 0, quoteSequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
function resetQuotes() { quoteSequence++; quoteRows.value = []; quoteSearched.value = false; quoteError.value = null; quoteLoading.value = false; quotePage.value = 1; quotePages.value = 0 }
watch(() => [form.customerId, form.cargo.destinationKey, form.cargo.currency, form.cargo.priceBasis], () => { resetQuotes(); form.quoteRef = null; quoteSource.value = null }, { flush: 'sync' })
watch(() => [open.value, target.value], async () => {
generation++; customerSequence++; clearTimeout(timer); resetQuotes(); customerLoading.value = false
if (!open.value) return
if (pending.value && pending.value.target !== target.value) { ElMessage.warning('请先核对或重试上一条结果未知的意向'); open.value = false; return }
error.value = null; customerError.value = null; customerSearched.value = false; customers.value = []
if (pending.value) Object.assign(form, structuredClone(toRaw(pending.value.body)))
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
}, { immediate: true })
function searchCustomers(keyword = '') {
if (lock.value || props.inquiry) return
clearTimeout(timer); const current = ++customerSequence
timer = setTimeout(async () => {
customerLoading.value = true; customerError.value = null
try { const rows = await listOfferCustomers(keyword.trim() || undefined); if (current === customerSequence && open.value) { customers.value = rows; customerSearched.value = true } }
catch (cause) { if (current === customerSequence) { customers.value = []; customerError.value = getApiErrorDetails(cause, '客户候选加载失败') } }
finally { if (current === customerSequence) customerLoading.value = false }
}, 250)
}
async function queryQuotes(page = 1) {
if (!props.offer || lock.value || !canQuote.value || !quoteReady.value) return
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
try { const value = await listOfferQuotes(props.offer.offerNo, { customerId: form.customerId, destinationKey: form.cargo.destinationKey, currency: form.cargo.currency as 'USD' | 'CNY', priceBasis: form.cargo.priceBasis, page, size: 20 }); if (current === quoteSequence && open.value) { quoteRows.value = value.records; quotePage.value = value.page; quotePages.value = value.pages; quoteSearched.value = true } }
catch (cause) { if (current === quoteSequence) quoteError.value = getApiErrorDetails(cause, '报价候选读取失败,可继续手工询价') }
finally { if (current === quoteSequence) quoteLoading.value = false }
}
const sourceKey = (row: FleetQuoteReference) => `${row.quoteNo}:${row.recordVersion}:${row.priceItem}`
function selectQuote(key: string) { const row = quoteRows.value.find(item => sourceKey(item) === key); if (row && !lock.value) { quoteSource.value = row; form.quoteRef = fleetQuoteReference(row); form.cargo.unitPrice = row.quotedUnitPrice } }
async function save() {
if (busy.value || !writable.value || !props.offer) return
if (!form.customerId || !form.cargo.destinationKey) { ElMessage.warning('请选择客户和本车卸货站'); return }
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="选填" /></el-form-item>
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="计价方式"><el-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
<el-form-item :label="form.cargo.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.cargo.unitPrice" :min="0" :precision="2" placeholder="未知可留空" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
</el-form>
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
</section>
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
</AppDialog>
</template>
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore()
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
const manage = access.can('platform:fleet:capacity-offer:manage')
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
return [{ key: 'view', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
]
</script>
<template><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
</DataTable></template>
<style scoped>.inquiry-reason{display:block;color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal}</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listCapacityOffers, saveCapacityOffer, commandCapacityOffer } from '../api/capacityOffers'
import { offerStatusLabels, type CapacityOffer, type OfferWrite, type OfferCommand } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip }>()
const emit = defineEmits<{ saved: [version: number] }>()
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const loaded = ref(false)
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
let generation = 0
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value))
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
try { const value = await listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(open, value => { generation++; if (value) { if (pending.value) return; void load() } })
async function execute() {
if (!pending.value || busy.value || !writable.value) return
const current = generation; busy.value = true; error.value = null
try { const p = pending.value; const result = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body); if (current !== generation) return; offer.value = result.offer; pending.value = null; emit('saved', result.offer.tripRecordVersion); guard.capture(); ElMessage.success('发布设置已更新') }
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
async function save() {
if (pending.value) { await execute(); return }
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: props.trip.tripNo, tripRecordVersion: offer.value?.tripRecordVersion ?? props.trip.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
await execute()
}
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
if (guard.dirty.value) { ElMessage.warning('请先保存发布信息,再执行发布状态操作'); return }
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
}
onBeforeUnmount(() => { generation++ })
</script>
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item>
</el-form>
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPublish" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('publish')">发布运力</el-button><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canResume" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('resume')">重新核对并恢复</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
</AppDialog></template>
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { createDriver, listDriverWarehouses, updateDriver, uploadDriverLicense } from '../api/drivers'
import type { DriverDetail, DriverDraft, DriverProfile, DriverWarehouseOption } from '../driverTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ driver: DriverDetail | null }>()
const emit = defineEmits<{ saved: [driver: DriverDetail] }>()
const access = useAccessPreviewStore()
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseLoading = ref(false), warehouseOptions = ref<DriverWarehouseOption[]>([])
const current = ref<DriverDetail | null>(null), selectedFile = ref<File | null>(null), fileInput = ref<HTMLInputElement | null>(null)
const emptyProfile = (): DriverProfile => ({ warehouseId: null, vehicleModels: null, languagesAndArea: null, medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null })
const form = reactive<DriverDraft>({ name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
const profile = computed(() => form.profile!)
const zones = ['America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver', 'Asia/Shanghai', 'Europe/London', 'UTC']
let warehouseRequest = 0, editSession = 0, lastSaved = ''
async function loadWarehouses() {
const request = ++warehouseRequest
warehouseLoading.value = true; warehouseError.value = null
try { const rows = await listDriverWarehouses(form.region); if (request === warehouseRequest) warehouseOptions.value = rows }
catch (cause) { if (request === warehouseRequest) { warehouseOptions.value = []; warehouseError.value = getApiErrorDetails(cause, '仓库候选加载失败') } }
finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeRegion() { profile.value.warehouseId = null; void loadWarehouses() }
watch([open, () => props.driver?.id], ([value]) => {
editSession++; saving.value = false
if (!value) { warehouseRequest++; return }
current.value = props.driver; selectedFile.value = null; error.value = null; lastSaved = ''
if (fileInput.value) fileInput.value.value = ''
Object.assign(form, props.driver ? { ...props.driver.details, profile: { ...emptyProfile(), ...props.driver.details.profile } }
: { name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
void loadWarehouses()
})
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
if (file && (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
}
selectedFile.value = file
}
async function save() {
if (saving.value) return
if (!form.name.trim() || !form.phone.trim() || !form.licenseZone.trim()) { ElMessage.warning('请填写姓名、电话和证照时区'); return }
saving.value = true; error.value = null
const session = editSession, sourceDriverId = props.driver?.id
const isCurrent = () => session === editSession && open.value && props.driver?.id === sourceDriverId
let persisted = false
try {
const details: DriverDraft = { ...form, name: form.name.trim(), phone: form.phone.trim(), profile: { ...profile.value } }
const fingerprint = JSON.stringify(details)
if (!current.value || fingerprint !== lastSaved) {
const savedDriver = current.value ? await updateDriver(current.value.id, current.value.recordVersion, details) : await createDriver(details)
if (!isCurrent()) return
current.value = savedDriver
lastSaved = fingerprint
}
persisted = true
if (selectedFile.value && current.value) {
const uploadedDriver = await uploadDriverLicense(current.value.id, current.value.recordVersion, selectedFile.value)
if (!isCurrent()) return
current.value = uploadedDriver
selectedFile.value = null
}
if (current.value) emit('saved', current.value)
ElMessage.success('司机档案已保存'); open.value = false
} catch (cause) {
if (!isCurrent()) return
if (persisted && current.value) emit('saved', current.value)
const details = getApiErrorDetails(cause, '司机保存失败')
error.value = { ...details, message: persisted ? `档案已保存,驾驶证上传失败:${details.message}。可直接重试。` : details.message }
} finally { if (isCurrent()) saving.value = false }
}
onBeforeUnmount(() => { warehouseRequest++; editSession++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @confirm="save">
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form driver-form" :disabled="saving" @submit.prevent="save">
<h3 class="full-width">基础信息</h3>
<el-form-item label="司机姓名" required><el-input v-model="form.name" maxlength="128" placeholder="请输入司机姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="form.phone" maxlength="64" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="司机归属"><el-select v-model="form.ownership"><el-option label="自有司机" value="OWNED" /><el-option label="外派司机" value="DISPATCHED" /></el-select></el-form-item>
<el-form-item label="区域"><el-select v-model="form.region" @change="changeRegion"><el-option label="国内" value="DOMESTIC" /><el-option label="海外" value="OVERSEAS" /></el-select></el-form-item>
<el-form-item label="所属仓库"><el-select v-model="profile.warehouseId" clearable filterable :loading="warehouseLoading" placeholder="选择所属仓库"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
<h3 class="full-width">驾驶资质</h3>
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
<el-form-item label="驾照签发日"><el-date-picker v-model="form.licenseIssuedOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="驾照到期日"><el-date-picker v-model="form.licenseExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<p class="full-width form-hint">驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p>
<h3 class="full-width">证照附件</h3>
<div class="full-width">
<label v-if="access.can(permission.driverUpload)">驾驶证(PDF / JPG / PNG,最大 10MiB)<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
<p class="form-hint">{{ selectedFile?.name || (current?.licenseFileId ? '已有驾驶证附件;上传新文件后需要重新审核。' : '可先保存档案,补齐驾驶证后才能通过审核。') }}</p>
</div>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充司机档案说明(选填)" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
.driver-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
.full-width { grid-column: 1 / -1; }
.driver-form h3 { margin: 0; }
.driver-form :deep(.el-select), .driver-form :deep(.el-date-editor) { width: 100%; }
.form-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadDriverLicense } from '../api/drivers'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ driverId: string; fileId: string; driverName: string }>()
const url = ref(''), contentType = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let request = 0
function release() { if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
async function load() {
const current = ++request
release(); error.value = null; loading.value = true
try {
const blob = await downloadDriverLicense(props.driverId, props.fileId)
if (current !== request || !open.value) return
contentType.value = blob.type; url.value = URL.createObjectURL(blob)
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '驾驶证读取失败') }
finally { if (current === request) loading.value = false }
}
watch([open, () => props.driverId, () => props.fileId], () => {
if (open.value && props.driverId && props.fileId) void load()
else { request++; release(); loading.value = false }
})
onBeforeUnmount(() => { request++; release() })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" title="驾驶证附件" width="620px" :show-confirm="false" cancel-text="关闭">
<p>{{ driverName }} · 驾驶证附件</p>
<LoadErrorState v-if="error" title="驾驶证读取失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading" class="license-preview">
<iframe v-if="url && contentType === 'application/pdf'" :src="url" title="驾驶证 PDF 预览" />
<img v-else-if="url" :src="url" :alt="`${driverName}的驾驶证`" />
</div>
<a v-if="url" :href="url" :download="`驾驶证.${contentType === 'application/pdf' ? 'pdf' : contentType === 'image/png' ? 'png' : 'jpg'}`">下载驾驶证</a>
</AppDialog>
</template>
<style scoped>
.license-preview { min-height: 160px; }
.license-preview iframe { width: 100%; height: 560px; border: 0; }
.license-preview img { display: block; max-width: 100%; max-height: 560px; margin: auto; object-fit: contain; }
</style>
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import FleetQuoteEditor from './FleetQuoteEditor.vue'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/fleetQuotes'
vi.mock('../api/fleetQuotes', async original => ({ ...(await original<typeof import('../api/fleetQuotes')>()), createFleetQuote: vi.fn(), updateFleetQuote: vi.fn() }))
const wrappers: VueWrapper[] = []
const input = (name: string) => ({ name, props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" />' })
const slot = { template: '<div><slot/></div>' }
async function mount() {
const wrapper = shallowMount(FleetQuoteEditor, { props: { modelValue: false, quote: null, kind: 'SALE' }, global: { stubs: {
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: { name: 'ElForm', props: ['disabled'], template: '<div><slot/></div>' },
ElFormItem: slot, ElInput: input('ElInput'), ElInputNumber: input('ElInputNumber'), ElDatePicker: input('ElDatePicker'), ElSelect: slot, ElOption: true,
FleetQuotePartySelect: input('FleetQuotePartySelect'), ElButton: { template: '<button><slot/></button>' }, ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises()
wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101')
wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Origin')
wrapper.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', 'QA-DEST')
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 10)
wrapper.findAllComponents({ name: 'ElInputNumber' })[2]!.vm.$emit('update:modelValue', 0)
wrapper.findAllComponents({ name: 'ElDatePicker' })[1]!.vm.$emit('update:modelValue', '2100-01-01')
await flushPromises(); return wrapper
}
async function clickSave(wrapper: VueWrapper) { await wrapper.findAll('button').find(b => ['保存报价', '按原请求重试'].includes(b.text()))!.trigger('click'); await flushPromises() }
beforeEach(() => vi.clearAllMocks()); afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
it('HTTP 200/A0409 unlocks the form so corrected content receives a new key', async () => {
const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new ApiResponseError('报价组合重复', 'A0409', 200))
await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(false); expect(wrapper.text()).not.toContain('按原请求重试')
wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Another-Origin')
vi.mocked(api.createFleetQuote).mockResolvedValue({ quoteNo: 'FQ-NEW', details: original[1], recordVersion: '0', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' })
await clickSave(wrapper)
const corrected = vi.mocked(api.createFleetQuote).mock.calls[1]!
expect(corrected[0]).not.toBe(original[0]); expect(corrected[1].originCity).toBe('QA-Another-Origin'); expect(corrected[1].fullDeliveryPrice).toBe(0)
})
it('unknown creation retains its exact key after viewing another quote', async () => {
const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new Error('Network unavailable'))
await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
const other: api.FleetQuote = { quoteNo: 'FQ-OTHER', details: { ...original[1], originCity: 'OTHER' }, recordVersion: '2', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' }
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: other, readonly: true, modelValue: true }); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: null, readonly: false, modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('按原请求重试'); vi.mocked(api.createFleetQuote).mockResolvedValue({ ...other, quoteNo: 'FQ-NEW', details: original[1] })
await clickSave(wrapper); expect(vi.mocked(api.createFleetQuote).mock.calls[1]).toEqual(original)
})
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import { createFleetQuote, updateFleetQuote, isFleetCommandRejected, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>()
const emit = defineEmits<{ saved: [] }>()
type QuoteForm = Omit<FleetQuoteDraft, 'distanceMiles'> & { distanceMiles: number | null }
const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '', destinationCode: '', distanceMiles: null, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: null, looseDeliveryPrice: null, validFrom: new Date().toISOString().slice(0, 10), validTo: '', remark: null })
const form = reactive<QuoteForm>(empty()), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
let pending: { idempotencyKey: string; details: FleetQuoteDraft } | null = null
const uncertain = ref(false)
watch([open, () => props.quote?.quoteNo], () => {
if (!open.value) return
uncertain.value = !props.quote && pending !== null
Object.assign(form, props.quote?.details || pending?.details || empty()); error.value = null
})
async function save() {
if (props.readonly || saving.value) return
const editing = props.quote
saving.value = true; error.value = null
try {
if (!form.partyId || !form.originCity.trim() || !form.destinationCode.trim() || !form.validFrom || !form.validTo) throw new Error('请选择往来单位并填写路线和有效期')
if (form.distanceMiles == null || !Number.isFinite(form.distanceMiles)) throw new Error('请填写有效的预估距离')
if (form.selfPickupPrice == null && form.fullDeliveryPrice == null && form.looseDeliveryPrice == null) throw new Error('至少填写一种价格,零表示明确零价')
const details: FleetQuoteDraft = { ...form, distanceMiles: form.distanceMiles }
if (editing) await updateFleetQuote(editing, details)
else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) }
if (!editing) pending = null
uncertain.value = false; open.value = false; emit('saved')
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && isFleetCommandRejected(e)) pending = null; uncertain.value = !editing && pending !== null }
finally { saving.value = false }
}
</script>
<template><AppDialog v-model="open" :title="readonly ? '报价详情' : quote ? '编辑报价' : '新增报价'" width="780px" class="admin-density-dialog" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="uncertain">保存结果尚未确认,重试将发送原请求和原内容。</p>
<el-form label-position="top" class="density-form quote-form" :disabled="readonly || saving || uncertain">
<el-form-item label="用途"><el-input :model-value="form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'" disabled /></el-form-item>
<el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="readonly || saving || uncertain" /></el-form-item>
<el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item>
<el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item>
<el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
<el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
<el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
<el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充报价说明" /></el-form-item>
</el-form><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
<template #footer><el-button @click="open = false">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></template>
</AppDialog></template>
<style scoped>.quote-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import FleetQuoteImport from './FleetQuoteImport.vue'
import { ApiResponseError } from '@/shared/api/http'
import { fleetQuoteCsvHeader } from '../fleetQuoteCsv'
import * as api from '../api/fleetQuotes'
vi.mock('../api/fleetQuotes', async original => ({ ...(await original<typeof import('../api/fleetQuotes')>()), importFleetQuotes: vi.fn() }))
const wrappers: VueWrapper[] = []
async function mount() {
const wrapper = shallowMount(FleetQuoteImport, { props: { modelValue: true, kind: 'SALE' }, global: { stubs: {
AppDialog: { template: '<div><slot/><slot name="footer"/></div>' },
FleetQuotePartySelect: { name: 'FleetQuotePartySelect', props: ['modelValue', 'kind', 'disabled'], emits: ['update:modelValue'], template: '<div/>' },
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, ApiErrorMeta: true,
} } }); wrappers.push(wrapper)
wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101'); await flushPromises(); return wrapper
}
async function pick(wrapper: VueWrapper, city = 'QA-FIRST', size = 100, text?: () => Promise<string>) {
Object.defineProperty(wrapper.find('input[type="file"]').element, 'files', { configurable: true, value: [{ size, text: text || (async () => `${fleetQuoteCsvHeader.join(',')}\n${city},QA-DEST,10,USD,,0,12,2026-01-01,2100-01-01,`) }] })
await wrapper.find('input[type="file"]').trigger('change'); await flushPromises()
}
async function save(wrapper: VueWrapper) { await wrapper.findAll('button').find(b => ['确认导入', '按原请求重试'].includes(b.text()))!.trigger('click'); await flushPromises() }
beforeEach(() => vi.clearAllMocks()); afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
it('definite import rejection unlocks file selection and corrected rows use a new key', async () => {
const wrapper = await mount(); await pick(wrapper)
vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new ApiResponseError('组合重复', 'A0409', 200)); await save(wrapper)
const first = vi.mocked(api.importFleetQuotes).mock.calls[0]!
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined()
await pick(wrapper, 'QA-CORRECTED'); vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
const second = vi.mocked(api.importFleetQuotes).mock.calls[1]!
expect(second[0]).not.toBe(first[0]); expect(second[1][0]?.originCity).toBe('QA-CORRECTED')
})
it('unknown result preserves original purpose, key and rows across closing and category changes', async () => {
const wrapper = await mount(); await pick(wrapper)
vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new Error('network')); await save(wrapper)
const call = vi.mocked(api.importFleetQuotes).mock.calls[0]!
const first = [call[0], call[1].map(row => ({ ...row }))]
await wrapper.setProps({ modelValue: false, kind: 'PURCHASE' }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'FleetQuotePartySelect' }).props('kind')).toBe('SALE')
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined()
vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
expect(vi.mocked(api.importFleetQuotes).mock.calls[1]).toEqual(first)
})
it('selecting an oversized replacement file cannot accidentally submit the previous valid file', async () => {
const wrapper = await mount(); await pick(wrapper); await pick(wrapper, 'TOO-LARGE', 500001)
expect(wrapper.findAll('button').find(b => b.text() === '确认导入')!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('QA-FIRST'); expect(api.importFleetQuotes).not.toHaveBeenCalled()
})
it('a late old file read cannot replace the most recently selected CSV', async () => {
const wrapper = await mount(); let release!: (value: string) => void
const slow = new Promise<string>(resolve => { release = resolve })
await pick(wrapper, 'SLOW', 100, () => slow)
await pick(wrapper, 'QA-LATEST')
release(`${fleetQuoteCsvHeader.join(',')}\nQA-OLD,QA-DEST,10,USD,,0,12,2026-01-01,2100-01-01,`); await flushPromises()
expect(wrapper.text()).toContain('QA-LATEST'); expect(wrapper.text()).not.toContain('QA-OLD')
vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
expect(vi.mocked(api.importFleetQuotes).mock.calls[0]?.[1][0]?.originCity).toBe('QA-LATEST')
})
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import { importFleetQuotes, saveFleetCsv, isFleetCommandRejected, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from '../fleetQuoteCsv'
const open = defineModel<boolean>({ default: false }), props = defineProps<{ kind: FleetQuoteKind }>(), emit = defineEmits<{ saved: [] }>()
const partyId = ref(''), rows = ref<FleetQuoteDraft[]>([]), source = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const pending = ref<{ key: string; rows: FleetQuoteDraft[] } | null>(null)
const activeKind = computed(() => pending.value?.rows[0]?.kind || props.kind)
let fileSequence = 0
watch([partyId, () => props.kind], () => { if (!pending.value) preview() })
function preview() { try { rows.value = source.value ? parseFleetQuoteCsv(source.value, props.kind, partyId.value) : []; error.value = null } catch (e) { rows.value = []; error.value = getApiErrorDetails(e, 'CSV校验失败') } }
async function pick(event: Event) {
if (saving.value || pending.value) return
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return
const current = ++fileSequence; rows.value = []; source.value = ''; error.value = null
if (file.size > 500000) { error.value = getApiErrorDetails(new Error('CSV不能超过500KB')); return }
try { const text = await file.text(); if (current !== fileSequence) return; source.value = text; preview() }
catch (e) { if (current === fileSequence) error.value = getApiErrorDetails(e, 'CSV读取失败,请重新选择文件') }
}
function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n'], { type: 'text/csv;charset=utf-8' }), '车队报价模板.csv') }
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { saving.value = false } }
onBeforeUnmount(() => { fileSequence++ })
</script>
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" :close-on-click-modal="false">
<p>本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
<FleetQuotePartySelect v-model="partyId" :kind="activeKind" :disabled="saving || !!pending" />
<el-button @click="template">下载CSV模板</el-button><input type="file" accept=".csv" aria-label="选择报价CSV" :disabled="saving || !!pending" @change="pick" />
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="pending">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
<p aria-live="polite">共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。</p>
<table class="density-table"><thead><tr><th>发货城市</th><th>目的仓码</th><th>距离(mile)</th><th>币种</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr></tbody></table>
<template #footer><el-button @click="open = false">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></template>
</AppDialog></template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { fleetQuoteCustomers, fleetQuoteSuppliers, type FleetQuoteKind } from '../api/fleetQuotes'
const value = defineModel<string>({ default: '' })
const props = defineProps<{ kind: FleetQuoteKind; partyName?: string; disabled?: boolean }>()
const access = useAccessPreviewStore(), rows = ref<{ id: string; name: string }[]>([]), loading = ref(false), error = ref('')
let sequence = 0
watch(() => props.kind, () => { rows.value = []; error.value = ''; sequence++ })
async function search(keyword = '') {
if (props.disabled || (props.kind === 'PURCHASE' && !access.can('platform:supplier:list'))) return
const current = ++sequence; loading.value = true; error.value = ''
try { const result = props.kind === 'SALE' ? await fleetQuoteCustomers(keyword) : await fleetQuoteSuppliers(keyword); if (sequence === current) rows.value = result }
catch { if (sequence === current) error.value = '目录加载失败,请重新搜索' } finally { if (sequence === current) loading.value = false }
}
onBeforeUnmount(() => { sequence++ })
</script>
<template><el-select v-model="value" filterable remote :remote-method="search" :loading="loading" :disabled="disabled || (kind === 'PURCHASE' && !access.can('platform:supplier:list'))" placeholder="搜索并选择往来单位" @focus="search()">
<el-option v-if="value && partyName && !rows.some(r => r.id === value)" :value="value" :label="partyName" />
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
</el-select><p v-if="error" role="alert">{{ error }}</p><p v-if="kind === 'PURCHASE' && !access.can('platform:supplier:list')">选择车队需要供应商列表权限。</p></template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { assignTrip } from '../api/trips'
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
import type { VehicleDetail } from '../types'
import type { TripDetail } from '../tripTypes'
const props = defineProps<{ detail: TripDetail }>()
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
let vehicleRequest = 0, driverRequest = 0, generation = 0
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
watch(() => props.detail.trip, trip => {
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
}, { immediate: true })
function searchVehicles(keyword = '') {
clearTimeout(vehicleTimer); const request = ++vehicleRequest
vehicleTimer = setTimeout(async () => {
vehicleLoading.value = true
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
finally { if (request === vehicleRequest) vehicleLoading.value = false }
}, 250)
}
function searchDrivers(keyword = '') {
clearTimeout(driverTimer); const request = ++driverRequest
driverTimer = setTimeout(async () => {
driverLoading.value = true
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
finally { if (request === driverRequest) driverLoading.value = false }
}, 250)
}
async function save() {
if (busy.value) return
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
const current = generation; busy.value = true; error.value = null
try {
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer) })
</script>
<template>
<div>
<LoadErrorState v-if="error" title="分配未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
</el-select></el-form-item>
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
</el-select></el-form-item>
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
</el-form>
</div>
</template>
<style scoped>.assignment-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.assignment-form :deep(.el-select){width:100%}</style>
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
const wrappers: VueWrapper[] = []
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] }))
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
})
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
const wrapper = mount('receipt'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled(); expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('retries the same incoming registration after an uncertain result without creating a new command', async () => {
vi.mocked(api.startTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking, replayed: true })
const wrapper = mount('incoming'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.startTripIncoming).mock.calls[0]![2]
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
async function load() {
if (!props.booking) return
const current = generation; loading.value = true; error.value = null
try {
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
if (current !== generation) return
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, target.value], () => {
generation++; busy.value = false; error.value = null; preview.value = null
if (!open.value || !props.booking) return
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (pending.value?.target !== target.value) pending.value = null
if (props.mode === 'receipt' || props.mode === 'view') void load()
}, { immediate: true })
async function save() {
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
const current = generation, booking = props.booking, mode = props.mode
if (!pending.value) {
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
else if (mode === 'bind') {
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
} else {
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
}
}
busy.value = true; error.value = null
try {
const body = pending.value.body
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form>
<template v-if="mode === 'receipt' || mode === 'view'">
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
</template>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style>
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingPanel from './TripBookingPanel.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking } from '../bookingTypes'
import type { Component } from 'vue'
const access = vi.hoisted(() => ({ writable: true, quote: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission === 'platform:fleet:quote:list' ? access.quote : access.writable }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
vi.mock('../api/tripBookings', () => ({ listTripBookingQuoteCandidates: vi.fn(), listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
const row: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
const wrappers: VueWrapper[] = []
function mount(realNumber?: Component, realInput?: Component) {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
AppDialog: { name: 'AppDialog', props: { modelValue: Boolean, showConfirm: Boolean, destroyOnClose: Boolean }, emits: ['confirm', 'update:modelValue'], template: realNumber ? '<div v-if="modelValue || !destroyOnClose" v-show="modelValue"><slot/></div>' : '<div v-if="modelValue"><slot/></div>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: realNumber || { ...field, name: 'ElInputNumber' }, ElInput: realInput || field, ElOption: true,
LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
beforeEach(() => { vi.clearAllMocks(); access.writable = true; access.quote = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('shows pool inquiries read-only without legacy edit, confirm or cancel actions', async () => {
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
const wrapper = mount(); await flushPromises()
for (const label of ['编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(node => node.text() === label)).toBe(false)
await button(wrapper, '详情').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('请从拼车需求池管理询价与承运')
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.confirmTripBooking).not.toHaveBeenCalled()
})
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
expect(api.confirmTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 4, recordVersion: 0, idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('changed')?.[0]).toEqual([5]); expect(api.listTripBookings).toHaveBeenCalledOnce()
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
})
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
const { ElInputNumber, ElInput } = await vi.importActual<typeof import('element-plus')>('element-plus')
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: saved, replayed: false })
const wrapper = mount(ElInputNumber, ElInput); await flushPromises()
await button(wrapper, '编辑').trigger('click'); await flushPromises()
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
await price.find('input').setValue('9'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
await button(wrapper, '详情').trigger('click'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.find<HTMLInputElement>('input').element.value).toBe('9.00')
expect(wrapper.text()).toContain('来源单价:16.00 USD')
expect(wrapper.text()).toContain('约定运费:18.00 USD')
})
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
})
it('does not expose booking writes to a read-only operator', async () => {
access.writable = false; const wrapper = mount(); await flushPromises()
for (const label of ['登记外接询价', '编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(button => button.text() === label)).toBe(false)
})
it('opens reactive booking data for editing and read-only details without losing the original row', async () => {
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
await button(wrapper, '详情').trigger('click'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
})
it('can edit an inquiry after closing its read-only details', async () => {
const wrapper = mount(); await flushPromises()
await button(wrapper, '详情').trigger('click'); await flushPromises()
const dialog = wrapper.findComponent({ name: 'AppDialog' })
expect(dialog.props('showConfirm')).toBe(false)
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(button(wrapper, '编辑').attributes('disabled')).toBeUndefined()
await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(true)
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, recordVersion: 1 }, replayed: false })
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', {
tripRecordVersion: 4, recordVersion: 0, cargo: row.cargo, quoteRef: null,
})
expect(api.listTripBookings).toHaveBeenCalledOnce()
})
const source = { quoteNo: 'FQ-ZERO', 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, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' }
const candidatePage = { records: [source], page: 1, size: 20, total: 1, pages: 1 }
it('queries only on request, keeps the exact source version and saves independently negotiated price', async () => {
vi.mocked(api.listTripBookingQuoteCandidates).mockResolvedValue(candidatePage)
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(api.listTripBookingQuoteCandidates).not.toHaveBeenCalled()
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
expect(api.listTripBookingQuoteCandidates).toHaveBeenCalledExactlyOnceWith('TR-1', { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD', priceBasis: 'PALLET', page: 1, size: 20 })
wrapper.findAllComponents({ name: 'ElSelect' }).find(component => component.attributes('data-testid') === 'booking-quote-candidate')!.vm.$emit('update:modelValue', 'FQ-ZERO:9007199254740993:LOOSE_DELIVERY'); await flushPromises()
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
expect(price.props('modelValue')).toBe(0)
price.vm.$emit('update:modelValue', 12.34); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }, cargo: expect.objectContaining({ unitPrice: 12.34 }) }))
})
it('ignores an old candidate response after changing destination and allows manual pricing', async () => {
let resolve!: (page: typeof candidatePage) => void
vi.mocked(api.listTripBookingQuoteCandidates).mockReturnValue(new Promise(done => { resolve = done }))
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
await button(wrapper, '查询报价库').trigger('click')
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'PRIVATE:ADDRESS'); await flushPromises()
resolve(candidatePage); await flushPromises()
expect(wrapper.find('[data-testid="booking-quote-candidate"]').exists()).toBe(false)
expect(button(wrapper, '查询报价库').attributes('disabled')).toBeDefined()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
})
it('clears a selected source explicitly without erasing the negotiated price', async () => {
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, quoteSource: source }] })
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
await button(wrapper, '改为手工询价').trigger('click')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: null, cargo: row.cargo }))
})
it('keeps manual entry available when candidate lookup fails and hides lookup without quote permission', async () => {
vi.mocked(api.listTripBookingQuoteCandidates).mockRejectedValue(new Error('仓库未配置时区'))
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
expect(wrapper.find('[role="alert"]').text()).toContain('报价库查询未完成,可继续手工询价')
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
access.quote = false
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
})

Some files were not shown because too many files have changed in this diff Show More