diff --git a/AGENTS.md b/AGENTS.md index b13bbd78..47159940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,12 +55,12 @@ | 入库单扣货、解除、凭证与历史 | `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 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/`,文件归 `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`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 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/` | -| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | -| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` 列表消费 `SEND_PICKUP_EMAIL`,邮件记录通过 `terminalDrayages.ts` 查询与重试;发送及换队成功只提示已入队,发送权限复用码头车队编辑权限。 | +| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | +| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | diff --git a/Jenkinsfile b/Jenkinsfile index 9a398fc0..0dec6ca3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ pipeline { agent { - label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}" + label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" } environment { @@ -10,6 +10,7 @@ pipeline { REGISTRY_CREDENTIALS = 'gitea-registry-token' NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine' NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine' + PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/admin-web' } options { @@ -22,6 +23,17 @@ pipeline { steps { script { env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false' + env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false' + if (env.IS_PRODUCTION == 'true') { + sh ''' + set -eu + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { + echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2 + exit 1 + } + ''' + } env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } sh 'git log -1 --oneline' @@ -29,7 +41,7 @@ pipeline { } stage('构建并部署测试前端') { - when { expression { env.IS_DEVELOPMENT != 'true' } } + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { sh ''' @@ -50,6 +62,61 @@ pipeline { } } + stage('构建并发布生产镜像') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + sh ''' + set -eu + export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" + rm -rf "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" + cleanup_registry() { + docker logout git.buggoglobal.com >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" + } + trap cleanup_registry EXIT HUP INT TERM + printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com + image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG" + docker build \ + --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ + --build-arg VITE_CLIENT_WEB_URL=https://app.buggoglobal.com/ \ + -t "$image" . + docker push "$image" + ''' + } + } + } + + stage('确认部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 admin.buggoglobal.com?", ok: '确认部署' + } + } + + stage('部署生产前端') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + lock(resource: 'firstleg-production-admin-web-deploy', inversePrecedence: true) { + withCredentials([ + 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') + ]) { + withEnv([ + 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend', + 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST + ]) { + sh "sh scripts/deploy-production.sh admin-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" + } + } + } + } + } + stage('构建并部署开发前端') { when { expression { env.IS_DEVELOPMENT == 'true' } } steps { @@ -82,7 +149,7 @@ pipeline { } stage('部署检查') { - when { expression { env.IS_DEVELOPMENT != 'true' } } + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { sh ''' set -eu @@ -96,7 +163,9 @@ pipeline { post { failure { sh ''' - if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then + if [ "${IS_PRODUCTION:-false}" = "true" ]; then + echo "生产部署失败,请查看上方远程部署日志" + elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then docker logs --tail=100 admin-web-development || true else docker compose -f docker-compose.test.yml logs --tail=100 || true diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 00000000..580f3a59 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,20 @@ +name: firstleg-prod-admin-web + +services: + admin-web: + image: ${IMAGE_REPOSITORY:?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:-9001}:80" + environment: + CLIENT_WEB_URL: "https://app.buggoglobal.com/" + API_UPSTREAM: "http://gateway:9000" + OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" + networks: + - infra-network + +networks: + infra-network: + external: true + name: firstleg-prod-infra-network diff --git a/docker-compose.test.yml b/docker-compose.test.yml index f522117c..3f75c718 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -11,9 +11,11 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9001}:80" + extra_hosts: + - "host.docker.internal:host-gateway" environment: # Runtime URL used by the secure customer-workbench handoff. Each # environment may override it without rebuilding the Admin image. CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}" - API_UPSTREAM: "http://test-api-cn.buggoglobal.com" - OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" + API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" + OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}" diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 00000000..821a7116 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 + exit 2 +fi + +SERVICE="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +COMPOSE_FILE="$4" +IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" +REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" +SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" +SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" +SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" +REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +run_ssh() { + ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" +REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" +run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' +set -eu +compose_tmp="$1" +compose_target="$2" +chmod 644 "$compose_tmp" +mv -f "$compose_tmp" "$compose_target" +REMOTE_PREPARE + +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" + +run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +service="$2" +image_repository="$3" +image_tag="$4" +compose_file="docker-compose.$service.yml" +project_name="firstleg-prod-$service" +cd "$remote_dir" +export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" + +compose() { + docker compose --project-name "$project_name" -f "$compose_file" "$@" +} + +container_id="$(compose ps --all --quiet "$service" | head -n 1)" +previous_image="" +if [ -n "$container_id" ]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" +fi + +if compose up -d --no-deps --pull never "$service" && \ + compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then + echo "生产前端部署成功: $image_repository:$image_tag" + compose ps "$service" + exit 0 +fi + +echo "生产前端健康检查失败,准备回滚" >&2 +compose logs --tail=100 "$service" >&2 || true +case "$previous_image" in + "$image_repository:"*) ;; + '') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;; + *) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;; +esac +previous_tag="${previous_image#"$image_repository:"}" +if [ "$previous_tag" = "$image_tag" ]; then + echo "旧镜像与新镜像一致,无法回滚" >&2 + exit 1 +fi +docker pull "$previous_image" +export IMAGE_TAG="$previous_tag" +compose up -d --no-deps --pull never "$service" +compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true +echo "已回滚至: $previous_image" >&2 +exit 1 +REMOTE_DEPLOY diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index a26cf17b..55caf51f 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8') const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8') const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8') +const productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8') const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8') const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8') @@ -38,8 +39,11 @@ describe('admin nginx deployment config', () => { }) it('declares an explicit overseas gateway upstream in container deployments', () => { - expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"') - expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"') + expect(testCompose).toContain('- "host.docker.internal:host-gateway"') + expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"') + expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"') + expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com') + expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com') expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"') expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.') expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') @@ -47,13 +51,17 @@ describe('admin nginx deployment config', () => { expect(config).toContain('proxy_set_header Host $proxy_host;') }) - it('runs each deployment on its target node without controller-local SSH files', () => { - expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'") + it('keeps local environments on target nodes and uses credential-managed SSH for production', () => { + expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'") expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'") expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d') expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh') expect(jenkinsfile).not.toContain('docker save') - expect(jenkinsfile).not.toMatch(/\bssh\b/) + expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'") + expect(jenkinsfile).toContain('git rev-parse origin/main') + expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80') + expect(productionCompose).toContain('API_UPSTREAM: "http://gateway:9000"') + expect(productionCompose).toContain('name: firstleg-prod-infra-network') }) it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => { diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts new file mode 100644 index 00000000..c2fe6c21 --- /dev/null +++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { overseasHttp } from '@/shared/api/overseasHttp' +import { listContainerArrivalTimes, updateContainerExpectedArrival } from './containerArrivalTimes' + +vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { post: vi.fn(), put: vi.fn() } })) + +describe('container arrival-time overseas API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('queries visible containers and updates the whole-container expected arrival', async () => { + vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) + vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) + + await listContainerArrivalTimes(['9001']) + await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }) + + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) + expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) + }) +}) diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts new file mode 100644 index 00000000..be5a29c1 --- /dev/null +++ b/src/modules/container-loading/api/containerArrivalTimes.ts @@ -0,0 +1,37 @@ +import { overseasHttp } from '@/shared/api/overseasHttp' +import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface ContainerArrivalTime { + sourceContainerBookingId: string + estimatedWarehouseArrivalAt: string | null + actualWarehouseArrivalAt: string | null + version: number +} + +const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query' + +function operationId(containerBookingId: string) { + return `container-expected-arrival-${containerBookingId}-${Date.now()}-${crypto.randomUUID()}` +} + +export async function listContainerArrivalTimes(sourceContainerBookingIds: string[]) { + if (!sourceContainerBookingIds.length) return [] + const response = await overseasHttp.post, ApiEnvelope>( + queryPath, + { sourceContainerBookingIds }, + ) + return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) })) +} + +export async function updateContainerExpectedArrival( + sourceContainerBookingId: string, + body: { estimatedWarehouseArrivalAt: string; expectedVersion: number; remark?: string }, +) { + const response = await overseasHttp.put, ApiEnvelope>( + `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/expected-arrival`, + body, + { headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } }, + ) + const result = unwrapApiResponse(response) + return { ...result, version: Number(result.version || 0) } +} diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 0cde8afb..1f8bef5d 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -159,6 +159,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan { fullPackageCount: numberValue(group.fullPackageCount), memberInboundIds: (group.memberInboundIds || []).map(id), })), + palletMembers: (raw.palletMembers || []).map((pallet) => ({ + palletId: id(pallet.palletId), + members: (pallet.members || []).map((member) => ({ + ...member, + accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), + availablePackageCount: numberValue(member.availablePackageCount), + })), + })), items: (raw.items || []).map(item), } } diff --git a/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts b/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts index 21ad88d8..2f04920a 100644 --- a/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts +++ b/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts @@ -57,4 +57,23 @@ describe('OceanBookingLinkDialog', () => { expect(wrapper.emitted('linked')).toHaveLength(1) wrapper.unmount() }) + + it('prefills and preserves a real container number recorded before booking linkage', async () => { + const existingContainer = { ...container, containerNo: 'MSCU6639870' } + const wrapper = mount(OceanBookingLinkDialog, { + props: { modelValue: true, container: existingContainer }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise } + expect(vm.containerNo).toBe('MSCU6639870') + + vm.candidateKey = '101:202' + await vm.save() + + expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', { + oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU6639870', expectedVersion: 7, + }) + wrapper.unmount() + }) }) diff --git a/src/modules/container-loading/components/OceanBookingLinkDialog.vue b/src/modules/container-loading/components/OceanBookingLinkDialog.vue index d4e9d600..91633f8b 100644 --- a/src/modules/container-loading/components/OceanBookingLinkDialog.vue +++ b/src/modules/container-loading/components/OceanBookingLinkDialog.vue @@ -31,7 +31,7 @@ function close() { emit('update:modelValue', false) } async function loadCandidates() { const target = props.container candidateKey.value = '' - containerNo.value = '' + containerNo.value = normalizeContainerNo(target?.containerNo) candidates.value = [] error.value = null if (!target) return diff --git a/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts new file mode 100644 index 00000000..b47c956c --- /dev/null +++ b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import SmartLoadingCandidateInbounds from './SmartLoadingCandidateInbounds.vue' +import type { SmartLoadingCandidate } from '../types' + +describe('sealed pallet composition', () => { + it('shows the two inbound portions, not the whole pallet count twice', async () => { + const candidate = { unitType: 'SEALED_PALLET', inboundCode: 'I180', availablePackageCount: 13, + groupMembers: [{ accountId: '1', inboundId: '180', inboundCode: 'I180', availablePackageCount: 3 }, + { accountId: '1', inboundId: '181', inboundCode: 'I181', availablePackageCount: 10 }] } as SmartLoadingCandidate + const wrapper = mount(SmartLoadingCandidateInbounds, { props: { candidate } }) + expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开') + expect(wrapper.text()).toContain('I180:3 箱') + expect(wrapper.text()).toContain('I181:10 箱') + expect(wrapper.text()).not.toContain('13 箱') + expect(wrapper.findAll('button')).toHaveLength(0) + }) + + it('renders the same composition when it comes from an assigned plan pallet', () => { + const wrapper = mount(SmartLoadingCandidateInbounds, { props: { + sealedPallet: true, + fallbackInboundCode: 'I180', + members: [ + { accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null }, + { accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null }, + ], + } }) + expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开') + expect(wrapper.text()).toContain('I180:6 箱') + expect(wrapper.text()).toContain('I181:5 箱') + }) +}) diff --git a/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue new file mode 100644 index 00000000..9c624d88 --- /dev/null +++ b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 3e6219c0..f297b23f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -6,6 +6,7 @@ import ContainerDetailView from './ContainerDetailView.vue' import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading' import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings' import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages' +import { listContainerArrivalTimes } from '../api/containerArrivalTimes' import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch' import { ref } from 'vue' import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists' @@ -22,6 +23,7 @@ vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() })) +vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() })) const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } } const overview = { container: { id: '9001', version: 1 }, loadingPlanId: null, documents: [], exception: { active: false }, timeline: [] } @@ -51,6 +53,7 @@ describe('ContainerDetailView empty return', () => { vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', items: [] } as never) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) }) it('loads departed gates exactly once when read permission arrives after the container detail', async () => { diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index b884bcd2..82ccf5c7 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -38,6 +38,7 @@ import { updateContainerPickupCode, } from '../api/platformContainerLoading' import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages' +import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes' import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' }) @@ -82,6 +83,7 @@ async function rememberWorkbenchTab(tabName: string | number) { } } const workbenchDetail = ref(null) +const arrivalTime = ref(null) const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action)) const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS')) @@ -294,8 +296,13 @@ async function load() { if (!containerId.value) return loading.value = true; error.value = null; overviewError.value = null try { - const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)]) + const [resource, aggregate, arrivals] = await Promise.all([ + getContainer(containerId.value, props.workbench), + getContainerOverview(containerId.value), + isOverseas.value ? listContainerArrivalTimes([containerId.value]) : Promise.resolve([]), + ]) workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate + arrivalTime.value = arrivals[0] || null if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview' if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate) if (isOverseas.value && resource.planStatus === 'COMPLETED' && canViewLoadingList.value) { @@ -575,7 +582,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action

提柜、卸柜与还空

确认还柜
-
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预约提柜
{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}
实际提柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
+
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 34afd62c..490ac617 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -14,6 +14,7 @@ import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } fr import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages' +import { listContainerArrivalTimes, updateContainerExpectedArrival } from '../api/containerArrivalTimes' import AppDialog from '@/shared/components/AppDialog.vue' import { containerLoadingRoutes } from '../routes' @@ -26,6 +27,7 @@ const accessCan = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ sendTerminalDrayageEmail: vi.fn(), getTerminalDrayageEmails: vi.fn(), retryTerminalDrayageEmail: vi.fn(), listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) +vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn(), updateContainerExpectedArrival: vi.fn() })) const drayage = { containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3, @@ -71,6 +73,8 @@ describe('ContainersView', () => { vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) + vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) @@ -463,7 +467,7 @@ describe('ContainersView', () => { }) it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { - vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) const router = createTestRouter() await router.push('/'); await router.isReady() const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } }) @@ -510,11 +514,11 @@ describe('ContainersView', () => { const oldSearch = vm.searchTerminalFleets('旧关键词') const latestSearch = vm.searchTerminalFleets('新车队') - expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '旧关键词' }) - expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '新车队' }) - resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 }) + expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '旧关键词' }) + expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '新车队' }) + resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never, { id: '504', name: '国内车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' } as never], page: 1, size: 20, total: 2, pages: 1 }) await latestSearch - resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 }) + resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 }) await oldSearch expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })]) wrapper.unmount() @@ -541,6 +545,27 @@ describe('ContainersView', () => { expect(source).not.toContain('linkContainerId') }) + it('edits the whole-container expected arrival inline and shows PDA receiving start as actual arrival', async () => { + const wrapper = await mountOverseasRoute() + expect(wrapper.text()).toContain('预计到仓') + expect(wrapper.text()).toContain('实际到仓') + expect(wrapper.text()).toContain('2026-09-26 09:00') + const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }) + .find((item) => item.props('placeholder') === '选择预计到仓') + expect(picker).toBeDefined() + + const vm = wrapper.findComponent(ContainersView).vm as unknown as { + rows: Array> + saveExpectedArrival: (row: Record, value: string) => Promise + } + await vm.saveExpectedArrival(vm.rows[0], '2026-09-25T16:00:00') + await flushPromises() + + expect(updateContainerExpectedArrival).toHaveBeenCalledWith('9001', { + estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', expectedVersion: 0, + }) + }) + it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => { const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC') vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({ @@ -717,7 +742,7 @@ describe('ContainersView', () => { }) it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => { - vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() @@ -737,7 +762,7 @@ describe('ContainersView', () => { }) it('clears the trucking supplier id and name together', async () => { - vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() @@ -797,9 +822,9 @@ describe('ContainersView trucking supplier contract', () => { it('uses active TRUCKING suppliers without allowing free-text carrier names', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') - expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })") - expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined") - expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'") + expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })") + expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined") + expect(source).toContain("supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE'") expect(source).toContain('filterable remote reserve-keyword remote-show-suffix') expect(source).toContain(':remote-method="searchTerminalFleets"') expect(source).toContain('selectTruckingSupplier') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 391c9479..d9c9283d 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -31,12 +31,12 @@ import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, - scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, type TerminalDrayageAction, type TerminalDrayageHistory, type TerminalDrayageView, } from '../api/terminalDrayages' +import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -78,6 +78,9 @@ const linkTarget = ref(null) const selectedTruckingSupplierId = ref('') const unresolvedTruckingSupplierName = ref(null) const drayages = ref>({}) +const arrivalTimes = ref>({}) +const arrivalInputs = ref>({}) +const arrivalSavingIds = ref([]) const drayageSavingIds = ref([]) const drayageActionOpen = ref(false) const drayageAction = ref>('SCHEDULE_PICKUP') @@ -187,13 +190,14 @@ const canCreate = computed(() => access.can('platform:firstleg:container:create' const canOverseasDocumentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:document:edit')) const canFleetEdit = computed(() => access.can('platform:firstleg:terminal-drayage:fleet:edit')) const canPickupAppointmentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit')) +const canExpectedArrivalEdit = computed(() => access.can('platform:overseas-warehouse:last-mile-order:edit')) const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm')) const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list')) const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const editorTitle = computed(() => form.id ? '编辑' : '新增') const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER'))) -const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING')) +const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS')) interface PortOption { value: string; code: string | null; name: string | null } function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' } function collectPorts(direction: 'departure' | 'destination'): PortOption[] { @@ -242,8 +246,8 @@ const allColumns: DataTableColumn[] = [ { key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' }, { key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => row.channelName || '待关联订舱' }, { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, - { key: 'pickupAppointmentAt', label: '预约提柜', width: 165, slotName: 'pickupAppointmentAt' }, - { key: 'actualPickupAt', label: '实际提柜', width: 165, slotName: 'actualPickupAt' }, + { key: 'pickupAppointmentAt', label: '预计到仓', width: 210, slotName: 'pickupAppointmentAt' }, + { key: 'actualPickupAt', label: '实际到仓', width: 165, slotName: 'actualPickupAt' }, { key: 'estimatedEmptyReturnAt', label: '预计还柜', width: 165, slotName: 'estimatedEmptyReturnAt' }, { key: 'actualEmptyReturnAt', label: '实际还柜', width: 165, slotName: 'actualEmptyReturnAt' }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 }, @@ -278,8 +282,8 @@ function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待 function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] } -function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已约提柜', PICKED_UP: '已提柜', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } -function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } +function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } +function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) } function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) { const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : canEmptyReturnConfirm.value @@ -461,8 +465,8 @@ async function loadTruckingSuppliers(): Promise { if (truckingSuppliersLoaded) return truckingSuppliers.value if (truckingSuppliersPromise) return truckingSuppliersPromise truckingSuppliersPromise = (async () => { - const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }) - const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE') + const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }) + const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE') truckingSuppliers.value = records truckingSuppliersLoaded = true return records @@ -475,10 +479,10 @@ async function searchTerminalFleets(keyword = '') { terminalFleetLoading.value = true try { const result = await listSuppliers({ - page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined, + page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined, }) if (sequence !== terminalFleetSearchSequence) return - terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE') + terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE') } catch (error) { if (sequence !== terminalFleetSearchSequence) return terminalFleetOptions.value = [] @@ -555,9 +559,7 @@ async function saveDrayageAction() { drayageActionSaving.value = true; drayageActionError.value = null try { const reason = drayageReason.value.trim() || undefined - const next = drayageAction.value === 'SCHEDULE_PICKUP' - ? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason }) - : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) + const next = await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) replaceDrayage(next); drayageActionOpen.value = false ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) await loadRows() @@ -569,6 +571,27 @@ async function saveDrayageAction() { } else drayageActionError.value = details } finally { drayageActionSaving.value = false } } +async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) { + const current = arrivalTimes.value[row.id] + if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return + arrivalSavingIds.value.push(row.id) + try { + const updated = await updateContainerExpectedArrival(row.id, { + estimatedWarehouseArrivalAt: value, + expectedVersion: current.version, + }) + arrivalTimes.value = { ...arrivalTimes.value, [row.id]: updated } + arrivalInputs.value = { ...arrivalInputs.value, [row.id]: updated.estimatedWarehouseArrivalAt || '' } + ElMessage.success('预计到仓时间已同步整柜,后续入库计划将自动带出') + } catch (error) { + ElMessage.error(getApiErrorDetails(error, '预计到仓时间保存失败').message) + const latest = await listContainerArrivalTimes([row.id]).catch(() => []) + if (latest[0]) { + arrivalTimes.value = { ...arrivalTimes.value, [row.id]: latest[0] } + arrivalInputs.value = { ...arrivalInputs.value, [row.id]: latest[0].estimatedWarehouseArrivalAt || '' } + } + } finally { arrivalSavingIds.value = arrivalSavingIds.value.filter((id) => id !== row.id) } +} async function openDrayageHistory(row: ContainerBookingResource) { drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true try { @@ -592,6 +615,10 @@ async function loadRows() { if (sequence !== listSequence) return rows.value = result.records as ContainerWorkbenchRow[]; total.value = result.total drayages.value = Object.fromEntries(rows.value.flatMap((row) => row.terminalDrayage ? [[row.id, row.terminalDrayage as TerminalDrayageView]] : [])) + const arrivalValues = isOverseas.value ? await listContainerArrivalTimes(rows.value.map((row) => row.id)) : [] + if (sequence !== listSequence) return + arrivalTimes.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item])) + arrivalInputs.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item.estimatedWarehouseArrivalAt || ''])) } catch (error) { if (sequence !== listSequence) return rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试') @@ -694,14 +721,14 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
- + - - + + @@ -745,7 +772,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + @@ -768,7 +795,10 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 01bd5161..6985904f 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -77,6 +77,7 @@ const plan = { canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], + palletMembers: [], items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], } const candidate = { @@ -492,7 +493,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票') expect(wrapper.text()).toContain('3 个入仓片段') expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B') - const buttons = wrapper.findAll('button').filter((button) => button.text().includes('整组分配')) + const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入') expect(buttons).toHaveLength(1) await buttons[0].trigger('click') await flushPromises() @@ -560,7 +561,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('CLG-9900 · 3 票') expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票') - const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))! + const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')! expect(button.attributes('disabled')).toBeDefined() expect(assignSmartLoadingGroup).not.toHaveBeenCalled() }) @@ -599,6 +600,73 @@ describe('GlobalSmartLoadingView', () => { expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version) }) + it('shows a shared pallet once with exact members and a text-only join action', async () => { + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ + ...candidate, unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-0025', indivisible: true, + availablePackageCount: 13, unitGrossWeightKg: 10, + groupMembers: [ + { accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }, + { accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 10, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }, + ], + }])) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + const buttons = wrapper.findAll('.el-table__body-wrapper button').filter(button => button.text() === '加入') + expect(buttons).toHaveLength(1) + expect(buttons[0].find('svg').exists()).toBe(false) + expect(wrapper.text()).toContain('I180:3 箱') + expect(wrapper.text()).toContain('I181:10 箱') + expect(wrapper.text()).toContain('130 kg') + expect(wrapper.get('.candidate-pane').text()).toContain('整托加入 · 不可拆') + expect(wrapper.get('.plan-pane').text()).toContain('按箱加入') + expect(wrapper.text()).toContain('装柜规则') + expect(wrapper.text()).not.toContain('分配单位') + wrapper.unmount() + }) + + it('uses one loading-rule column and expandable group details in both workbenches', () => { + for (const page of ['GlobalSmartLoadingView.vue', 'SmartLoadingWorkbenchView.vue']) { + const source = readFileSync(resolve(process.cwd(), `src/modules/container-loading/pages/${page}`), 'utf8') + expect(source.match(/label: '装柜规则'/g)).toHaveLength(2) + expect(source).not.toContain("label: '分配单位'") + expect(source).not.toContain('