Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev
This commit is contained in:
@@ -7,6 +7,7 @@ dist/
|
|||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
.idea/
|
||||||
.impeccable/
|
.impeccable/
|
||||||
.codex-temp/
|
.codex-temp/
|
||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
|
|||||||
@@ -42,15 +42,16 @@
|
|||||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||||
| 海外 PDA 拣货员与实际收货仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | `WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId`;候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓,服务端约束在 `iam/`,Claim 与实时校验联查 `common-security/`、`lastleg-service/security/` |
|
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||||
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` |
|
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` |
|
||||||
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
||||||
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
|
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
|
||||||
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/`;详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue` | 详情承载四类当前资料、PDA 证据、异常历史与整柜国内出仓门禁;Backend `firstleg/loading/`、`firstleg/split/`、`outbound/dispatch/`、`warehouse/`、`file/` |
|
| 货柜、排柜、预装/装柜清单与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。整柜报关按 `loadingPlanId` 展示,逐资料完成,全买单柜使用 `complete-all-buy`;Backend `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` |
|
||||||
|
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||||
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
|
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
|
||||||
| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 |
|
| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 |
|
||||||
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
| 拖车、历史报关协同、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料改从货柜详情进入,不得在旧模块复制一套;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||||
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
|
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
|
||||||
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
|
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
|
||||||
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
|
|
||||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` |
|
| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 按 `sourceContainerBookingId` 消费 `/orders/container-summaries` 的 `allPlansReady`、计划统计与冻结 `plannedArrivalAt`,详情由服务端返回全柜订单且不受列表分页截断;不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` |
|
||||||
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||||
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
||||||
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
|
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
|
|
||||||
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||||
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
||||||
- 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
||||||
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
|
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
|
||||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||||
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
|
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
|
||||||
|
|||||||
Vendored
+1
-1
@@ -70,7 +70,7 @@ pipeline {
|
|||||||
docker build \
|
docker build \
|
||||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||||
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
|
--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \
|
||||||
-t "$image" .
|
-t "$image" .
|
||||||
export IMAGE_TAG="$SOURCE_TAG"
|
export IMAGE_TAG="$SOURCE_TAG"
|
||||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
|
|||||||
|
|
||||||
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
|
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
|
||||||
|
|
||||||
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时设置容器环境变量 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。
|
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。
|
||||||
|
|
||||||
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:9001:80"
|
- "127.0.0.1:9001:80"
|
||||||
environment:
|
environment:
|
||||||
CLIENT_WEB_URL: "http://8.163.49.32:9002"
|
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"
|
||||||
API_UPSTREAM: "http://firstleg-development-gateway:9000"
|
API_UPSTREAM: "http://firstleg-development-gateway:9000"
|
||||||
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
|
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -11,12 +11,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-9001}:80"
|
- "${HOST_PORT:-9001}:80"
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
environment:
|
environment:
|
||||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
|
# Runtime URL used by the secure customer-workbench handoff. Each
|
||||||
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
|
# environment may override it without rebuilding the Admin image.
|
||||||
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
|
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"
|
||||||
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
|
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
|
||||||
API_UPSTREAM: "http://host.docker.internal:9000"
|
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
|
||||||
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ server {
|
|||||||
|
|
||||||
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
|
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
|
||||||
location /api/ {
|
location /api/ {
|
||||||
client_max_body_size 12m;
|
client_max_body_size 55m;
|
||||||
proxy_pass ${API_UPSTREAM};
|
proxy_pass ${API_UPSTREAM};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $proxy_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
@@ -32,11 +32,11 @@ server {
|
|||||||
|
|
||||||
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
||||||
location /overseas-api/ {
|
location /overseas-api/ {
|
||||||
client_max_body_size 12m;
|
client_max_body_size 55m;
|
||||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
||||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $proxy_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsc" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../typescript/bin/tsc" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsc
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsc" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\typescript\bin\tsc" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsserver" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../typescript/bin/tsserver" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsserver
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsserver" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\typescript\bin\tsserver" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../vite/bin/vite.js" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../vite/bin/vite.js" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vite/bin/vite.js
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\vite\bin\vite.js" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\vite\bin\vite.js" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../vitest/vitest.mjs" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vitest/vitest.mjs
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\vitest\vitest.mjs" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\vitest\vitest.mjs" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vue-tsc/bin/vue-tsc.js
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
{
|
|
||||||
"hoistedDependencies": {
|
|
||||||
"undici-types@7.18.2": {
|
|
||||||
"undici-types": "private"
|
|
||||||
},
|
|
||||||
"@rolldown/pluginutils@1.0.1": {
|
|
||||||
"@rolldown/pluginutils": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-dom@3.5.40": {
|
|
||||||
"@vue/compiler-dom": "private"
|
|
||||||
},
|
|
||||||
"js-beautify@1.15.4": {
|
|
||||||
"js-beautify": "private"
|
|
||||||
},
|
|
||||||
"vue-component-type-helpers@3.3.8": {
|
|
||||||
"vue-component-type-helpers": "private"
|
|
||||||
},
|
|
||||||
"@vue/server-renderer@3.5.40": {
|
|
||||||
"@vue/server-renderer": "private"
|
|
||||||
},
|
|
||||||
"follow-redirects@1.16.0(debug@4.4.3)": {
|
|
||||||
"follow-redirects": "private"
|
|
||||||
},
|
|
||||||
"form-data@4.0.6": {
|
|
||||||
"form-data": "private"
|
|
||||||
},
|
|
||||||
"https-proxy-agent@5.0.1": {
|
|
||||||
"https-proxy-agent": "private"
|
|
||||||
},
|
|
||||||
"proxy-from-env@2.1.0": {
|
|
||||||
"proxy-from-env": "private"
|
|
||||||
},
|
|
||||||
"@ctrl/tinycolor@4.2.0": {
|
|
||||||
"@ctrl/tinycolor": "private"
|
|
||||||
},
|
|
||||||
"@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@element-plus/icons-vue": "private"
|
|
||||||
},
|
|
||||||
"@floating-ui/dom@1.8.0": {
|
|
||||||
"@floating-ui/dom": "private"
|
|
||||||
},
|
|
||||||
"@sxzz/popperjs-es@2.11.8": {
|
|
||||||
"@popperjs/core": "private"
|
|
||||||
},
|
|
||||||
"@types/lodash@4.17.24": {
|
|
||||||
"@types/lodash": "private"
|
|
||||||
},
|
|
||||||
"@types/lodash-es@4.17.12": {
|
|
||||||
"@types/lodash-es": "private"
|
|
||||||
},
|
|
||||||
"@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@vueuse/core": "private"
|
|
||||||
},
|
|
||||||
"async-validator@4.2.5": {
|
|
||||||
"async-validator": "private"
|
|
||||||
},
|
|
||||||
"dayjs@1.11.21": {
|
|
||||||
"dayjs": "private"
|
|
||||||
},
|
|
||||||
"lodash@4.18.1": {
|
|
||||||
"lodash": "private"
|
|
||||||
},
|
|
||||||
"lodash-es@4.18.1": {
|
|
||||||
"lodash-es": "private"
|
|
||||||
},
|
|
||||||
"lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)": {
|
|
||||||
"lodash-unified": "private"
|
|
||||||
},
|
|
||||||
"memoize-one@6.0.0": {
|
|
||||||
"memoize-one": "private"
|
|
||||||
},
|
|
||||||
"normalize-wheel-es@1.2.0": {
|
|
||||||
"normalize-wheel-es": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/css-color@5.1.11": {
|
|
||||||
"@asamuzakjp/css-color": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/dom-selector@7.1.1": {
|
|
||||||
"@asamuzakjp/dom-selector": "private"
|
|
||||||
},
|
|
||||||
"@bramus/specificity@2.4.2": {
|
|
||||||
"@bramus/specificity": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)": {
|
|
||||||
"@csstools/css-syntax-patches-for-csstree": "private"
|
|
||||||
},
|
|
||||||
"@exodus/bytes@1.15.1": {
|
|
||||||
"@exodus/bytes": "private"
|
|
||||||
},
|
|
||||||
"css-tree@3.2.1": {
|
|
||||||
"css-tree": "private"
|
|
||||||
},
|
|
||||||
"data-urls@7.0.0": {
|
|
||||||
"data-urls": "private"
|
|
||||||
},
|
|
||||||
"decimal.js@10.6.0": {
|
|
||||||
"decimal.js": "private"
|
|
||||||
},
|
|
||||||
"html-encoding-sniffer@6.0.0": {
|
|
||||||
"html-encoding-sniffer": "private"
|
|
||||||
},
|
|
||||||
"is-potential-custom-element-name@1.0.1": {
|
|
||||||
"is-potential-custom-element-name": "private"
|
|
||||||
},
|
|
||||||
"lru-cache@11.5.2": {
|
|
||||||
"lru-cache": "private"
|
|
||||||
},
|
|
||||||
"parse5@8.0.1": {
|
|
||||||
"parse5": "private"
|
|
||||||
},
|
|
||||||
"saxes@6.0.0": {
|
|
||||||
"saxes": "private"
|
|
||||||
},
|
|
||||||
"symbol-tree@3.2.4": {
|
|
||||||
"symbol-tree": "private"
|
|
||||||
},
|
|
||||||
"tough-cookie@6.0.2": {
|
|
||||||
"tough-cookie": "private"
|
|
||||||
},
|
|
||||||
"undici@7.29.0": {
|
|
||||||
"undici": "private"
|
|
||||||
},
|
|
||||||
"w3c-xmlserializer@5.0.0": {
|
|
||||||
"w3c-xmlserializer": "private"
|
|
||||||
},
|
|
||||||
"webidl-conversions@8.0.1": {
|
|
||||||
"webidl-conversions": "private"
|
|
||||||
},
|
|
||||||
"whatwg-mimetype@5.0.0": {
|
|
||||||
"whatwg-mimetype": "private"
|
|
||||||
},
|
|
||||||
"whatwg-url@16.0.1": {
|
|
||||||
"whatwg-url": "private"
|
|
||||||
},
|
|
||||||
"xml-name-validator@5.0.0": {
|
|
||||||
"xml-name-validator": "private"
|
|
||||||
},
|
|
||||||
"@vue/devtools-api@8.2.1": {
|
|
||||||
"@vue/devtools-api": "private"
|
|
||||||
},
|
|
||||||
"nostics@1.2.0": {
|
|
||||||
"nostics": "private"
|
|
||||||
},
|
|
||||||
"local-pkg@1.2.1": {
|
|
||||||
"local-pkg": "private"
|
|
||||||
},
|
|
||||||
"magic-string@0.30.21": {
|
|
||||||
"magic-string": "private"
|
|
||||||
},
|
|
||||||
"picomatch@4.0.5": {
|
|
||||||
"picomatch": "private"
|
|
||||||
},
|
|
||||||
"unimport@5.7.0": {
|
|
||||||
"unimport": "private"
|
|
||||||
},
|
|
||||||
"unplugin@2.3.11": {
|
|
||||||
"unplugin": "private"
|
|
||||||
},
|
|
||||||
"unplugin-utils@0.3.2": {
|
|
||||||
"unplugin-utils": "private"
|
|
||||||
},
|
|
||||||
"chokidar@5.0.0": {
|
|
||||||
"chokidar": "private"
|
|
||||||
},
|
|
||||||
"mlly@1.8.2": {
|
|
||||||
"mlly": "private"
|
|
||||||
},
|
|
||||||
"obug@2.1.4": {
|
|
||||||
"obug": "private"
|
|
||||||
},
|
|
||||||
"tinyglobby@0.2.17": {
|
|
||||||
"tinyglobby": "private"
|
|
||||||
},
|
|
||||||
"lightningcss@1.33.0": {
|
|
||||||
"lightningcss": "private"
|
|
||||||
},
|
|
||||||
"postcss@8.5.23": {
|
|
||||||
"postcss": "private"
|
|
||||||
},
|
|
||||||
"rolldown@1.1.5": {
|
|
||||||
"rolldown": "private"
|
|
||||||
},
|
|
||||||
"yaml@2.9.0": {
|
|
||||||
"yaml": "private"
|
|
||||||
},
|
|
||||||
"@vitest/expect@4.1.10": {
|
|
||||||
"@vitest/expect": "private"
|
|
||||||
},
|
|
||||||
"@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))": {
|
|
||||||
"@vitest/mocker": "private"
|
|
||||||
},
|
|
||||||
"@vitest/pretty-format@4.1.10": {
|
|
||||||
"@vitest/pretty-format": "private"
|
|
||||||
},
|
|
||||||
"@vitest/runner@4.1.10": {
|
|
||||||
"@vitest/runner": "private"
|
|
||||||
},
|
|
||||||
"@vitest/snapshot@4.1.10": {
|
|
||||||
"@vitest/snapshot": "private"
|
|
||||||
},
|
|
||||||
"@vitest/spy@4.1.10": {
|
|
||||||
"@vitest/spy": "private"
|
|
||||||
},
|
|
||||||
"@vitest/utils@4.1.10": {
|
|
||||||
"@vitest/utils": "private"
|
|
||||||
},
|
|
||||||
"es-module-lexer@2.3.1": {
|
|
||||||
"es-module-lexer": "private"
|
|
||||||
},
|
|
||||||
"expect-type@1.4.0": {
|
|
||||||
"expect-type": "private"
|
|
||||||
},
|
|
||||||
"pathe@2.0.3": {
|
|
||||||
"pathe": "private"
|
|
||||||
},
|
|
||||||
"std-env@4.2.0": {
|
|
||||||
"std-env": "private"
|
|
||||||
},
|
|
||||||
"tinybench@2.9.0": {
|
|
||||||
"tinybench": "private"
|
|
||||||
},
|
|
||||||
"tinyexec@1.2.4": {
|
|
||||||
"tinyexec": "private"
|
|
||||||
},
|
|
||||||
"tinyrainbow@3.1.0": {
|
|
||||||
"tinyrainbow": "private"
|
|
||||||
},
|
|
||||||
"why-is-node-running@2.3.0": {
|
|
||||||
"why-is-node-running": "private"
|
|
||||||
},
|
|
||||||
"@babel/generator@8.0.0": {
|
|
||||||
"@babel/generator": "private"
|
|
||||||
},
|
|
||||||
"@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@vue-macros/common": "private"
|
|
||||||
},
|
|
||||||
"ast-walker-scope@0.9.0": {
|
|
||||||
"ast-walker-scope": "private"
|
|
||||||
},
|
|
||||||
"json5@2.2.3": {
|
|
||||||
"json5": "private"
|
|
||||||
},
|
|
||||||
"muggle-string@0.4.1": {
|
|
||||||
"muggle-string": "private"
|
|
||||||
},
|
|
||||||
"scule@1.3.0": {
|
|
||||||
"scule": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-sfc@3.5.40": {
|
|
||||||
"@vue/compiler-sfc": "private"
|
|
||||||
},
|
|
||||||
"@volar/typescript@2.4.28": {
|
|
||||||
"@volar/typescript": "private"
|
|
||||||
},
|
|
||||||
"@vue/language-core@3.3.8": {
|
|
||||||
"@vue/language-core": "private"
|
|
||||||
},
|
|
||||||
"@vue/runtime-dom@3.5.40": {
|
|
||||||
"@vue/runtime-dom": "private"
|
|
||||||
},
|
|
||||||
"@vue/shared@3.5.40": {
|
|
||||||
"@vue/shared": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/generational-cache@1.0.1": {
|
|
||||||
"@asamuzakjp/generational-cache": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
|
|
||||||
"@csstools/css-calc": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
|
|
||||||
"@csstools/css-color-parser": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": {
|
|
||||||
"@csstools/css-parser-algorithms": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-tokenizer@4.0.0": {
|
|
||||||
"@csstools/css-tokenizer": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/nwsapi@2.3.9": {
|
|
||||||
"@asamuzakjp/nwsapi": "private"
|
|
||||||
},
|
|
||||||
"bidi-js@1.0.3": {
|
|
||||||
"bidi-js": "private"
|
|
||||||
},
|
|
||||||
"@babel/parser@8.0.4": {
|
|
||||||
"@babel/parser": "private"
|
|
||||||
},
|
|
||||||
"@babel/types@8.0.4": {
|
|
||||||
"@babel/types": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/gen-mapping@0.3.13": {
|
|
||||||
"@jridgewell/gen-mapping": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/trace-mapping@0.3.31": {
|
|
||||||
"@jridgewell/trace-mapping": "private"
|
|
||||||
},
|
|
||||||
"@types/jsesc@2.5.1": {
|
|
||||||
"@types/jsesc": "private"
|
|
||||||
},
|
|
||||||
"jsesc@3.1.0": {
|
|
||||||
"jsesc": "private"
|
|
||||||
},
|
|
||||||
"@floating-ui/core@1.8.0": {
|
|
||||||
"@floating-ui/core": "private"
|
|
||||||
},
|
|
||||||
"@floating-ui/utils@0.2.12": {
|
|
||||||
"@floating-ui/utils": "private"
|
|
||||||
},
|
|
||||||
"@standard-schema/spec@1.1.0": {
|
|
||||||
"@standard-schema/spec": "private"
|
|
||||||
},
|
|
||||||
"@types/chai@5.2.3": {
|
|
||||||
"@types/chai": "private"
|
|
||||||
},
|
|
||||||
"chai@6.2.2": {
|
|
||||||
"chai": "private"
|
|
||||||
},
|
|
||||||
"estree-walker@3.0.3": {
|
|
||||||
"estree-walker": "private"
|
|
||||||
},
|
|
||||||
"convert-source-map@2.0.0": {
|
|
||||||
"convert-source-map": "private"
|
|
||||||
},
|
|
||||||
"@volar/language-core@2.4.28": {
|
|
||||||
"@volar/language-core": "private"
|
|
||||||
},
|
|
||||||
"path-browserify@1.0.1": {
|
|
||||||
"path-browserify": "private"
|
|
||||||
},
|
|
||||||
"vscode-uri@3.1.0": {
|
|
||||||
"vscode-uri": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-core@3.5.40": {
|
|
||||||
"@vue/compiler-core": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-ssr@3.5.40": {
|
|
||||||
"@vue/compiler-ssr": "private"
|
|
||||||
},
|
|
||||||
"source-map-js@1.2.1": {
|
|
||||||
"source-map-js": "private"
|
|
||||||
},
|
|
||||||
"@vue/devtools-kit@8.2.1": {
|
|
||||||
"@vue/devtools-kit": "private"
|
|
||||||
},
|
|
||||||
"alien-signals@3.2.1": {
|
|
||||||
"alien-signals": "private"
|
|
||||||
},
|
|
||||||
"@vue/reactivity@3.5.40": {
|
|
||||||
"@vue/reactivity": "private"
|
|
||||||
},
|
|
||||||
"@vue/runtime-core@3.5.40": {
|
|
||||||
"@vue/runtime-core": "private"
|
|
||||||
},
|
|
||||||
"csstype@3.2.3": {
|
|
||||||
"csstype": "private"
|
|
||||||
},
|
|
||||||
"ast-kit@2.2.0": {
|
|
||||||
"ast-kit": "private"
|
|
||||||
},
|
|
||||||
"magic-string-ast@1.0.3": {
|
|
||||||
"magic-string-ast": "private"
|
|
||||||
},
|
|
||||||
"@types/web-bluetooth@0.0.21": {
|
|
||||||
"@types/web-bluetooth": "private"
|
|
||||||
},
|
|
||||||
"@vueuse/metadata@14.3.0": {
|
|
||||||
"@vueuse/metadata": "private"
|
|
||||||
},
|
|
||||||
"@vueuse/shared@14.3.0(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@vueuse/shared": "private"
|
|
||||||
},
|
|
||||||
"readdirp@5.0.0": {
|
|
||||||
"readdirp": "private"
|
|
||||||
},
|
|
||||||
"mdn-data@2.27.1": {
|
|
||||||
"mdn-data": "private"
|
|
||||||
},
|
|
||||||
"debug@4.4.3": {
|
|
||||||
"debug": "private"
|
|
||||||
},
|
|
||||||
"asynckit@0.4.0": {
|
|
||||||
"asynckit": "private"
|
|
||||||
},
|
|
||||||
"combined-stream@1.0.8": {
|
|
||||||
"combined-stream": "private"
|
|
||||||
},
|
|
||||||
"es-set-tostringtag@2.1.0": {
|
|
||||||
"es-set-tostringtag": "private"
|
|
||||||
},
|
|
||||||
"hasown@2.0.4": {
|
|
||||||
"hasown": "private"
|
|
||||||
},
|
|
||||||
"mime-types@2.1.35": {
|
|
||||||
"mime-types": "private"
|
|
||||||
},
|
|
||||||
"agent-base@6.0.2": {
|
|
||||||
"agent-base": "private"
|
|
||||||
},
|
|
||||||
"config-chain@1.1.13": {
|
|
||||||
"config-chain": "private"
|
|
||||||
},
|
|
||||||
"editorconfig@1.0.7": {
|
|
||||||
"editorconfig": "private"
|
|
||||||
},
|
|
||||||
"glob@10.5.0": {
|
|
||||||
"glob": "private"
|
|
||||||
},
|
|
||||||
"js-cookie@3.0.8": {
|
|
||||||
"js-cookie": "private"
|
|
||||||
},
|
|
||||||
"nopt@7.2.1": {
|
|
||||||
"nopt": "private"
|
|
||||||
},
|
|
||||||
"detect-libc@2.1.2": {
|
|
||||||
"detect-libc": "private"
|
|
||||||
},
|
|
||||||
"lightningcss-win32-x64-msvc@1.33.0": {
|
|
||||||
"lightningcss-win32-x64-msvc": "private"
|
|
||||||
},
|
|
||||||
"pkg-types@2.3.1": {
|
|
||||||
"pkg-types": "private"
|
|
||||||
},
|
|
||||||
"quansync@0.2.11": {
|
|
||||||
"quansync": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/sourcemap-codec@1.5.5": {
|
|
||||||
"@jridgewell/sourcemap-codec": "private"
|
|
||||||
},
|
|
||||||
"acorn@8.17.0": {
|
|
||||||
"acorn": "private"
|
|
||||||
},
|
|
||||||
"ufo@1.6.4": {
|
|
||||||
"ufo": "private"
|
|
||||||
},
|
|
||||||
"entities@8.0.0": {
|
|
||||||
"entities": "private"
|
|
||||||
},
|
|
||||||
"nanoid@3.3.16": {
|
|
||||||
"nanoid": "private"
|
|
||||||
},
|
|
||||||
"picocolors@1.1.1": {
|
|
||||||
"picocolors": "private"
|
|
||||||
},
|
|
||||||
"@oxc-project/types@0.139.0": {
|
|
||||||
"@oxc-project/types": "private"
|
|
||||||
},
|
|
||||||
"@rolldown/binding-win32-x64-msvc@1.1.5": {
|
|
||||||
"@rolldown/binding-win32-x64-msvc": "private"
|
|
||||||
},
|
|
||||||
"xmlchars@2.2.0": {
|
|
||||||
"xmlchars": "private"
|
|
||||||
},
|
|
||||||
"fdir@6.5.0(picomatch@4.0.5)": {
|
|
||||||
"fdir": "private"
|
|
||||||
},
|
|
||||||
"tldts@7.4.9": {
|
|
||||||
"tldts": "private"
|
|
||||||
},
|
|
||||||
"escape-string-regexp@5.0.0": {
|
|
||||||
"escape-string-regexp": "private"
|
|
||||||
},
|
|
||||||
"strip-literal@3.1.0": {
|
|
||||||
"strip-literal": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/remapping@2.3.5": {
|
|
||||||
"@jridgewell/remapping": "private"
|
|
||||||
},
|
|
||||||
"webpack-virtual-modules@0.6.2": {
|
|
||||||
"webpack-virtual-modules": "private"
|
|
||||||
},
|
|
||||||
"tr46@6.0.0": {
|
|
||||||
"tr46": "private"
|
|
||||||
},
|
|
||||||
"siginfo@2.0.0": {
|
|
||||||
"siginfo": "private"
|
|
||||||
},
|
|
||||||
"stackback@0.0.2": {
|
|
||||||
"stackback": "private"
|
|
||||||
},
|
|
||||||
"@babel/helper-string-parser@8.0.0": {
|
|
||||||
"@babel/helper-string-parser": "private"
|
|
||||||
},
|
|
||||||
"@babel/helper-validator-identifier@8.0.4": {
|
|
||||||
"@babel/helper-validator-identifier": "private"
|
|
||||||
},
|
|
||||||
"@csstools/color-helpers@6.1.0": {
|
|
||||||
"@csstools/color-helpers": "private"
|
|
||||||
},
|
|
||||||
"@types/deep-eql@4.0.2": {
|
|
||||||
"@types/deep-eql": "private"
|
|
||||||
},
|
|
||||||
"assertion-error@2.0.1": {
|
|
||||||
"assertion-error": "private"
|
|
||||||
},
|
|
||||||
"@volar/source-map@2.4.28": {
|
|
||||||
"@volar/source-map": "private"
|
|
||||||
},
|
|
||||||
"@vue/devtools-shared@8.2.1": {
|
|
||||||
"@vue/devtools-shared": "private"
|
|
||||||
},
|
|
||||||
"birpc@2.9.0": {
|
|
||||||
"birpc": "private"
|
|
||||||
},
|
|
||||||
"hookable@5.5.3": {
|
|
||||||
"hookable": "private"
|
|
||||||
},
|
|
||||||
"perfect-debounce@2.1.0": {
|
|
||||||
"perfect-debounce": "private"
|
|
||||||
},
|
|
||||||
"require-from-string@2.0.2": {
|
|
||||||
"require-from-string": "private"
|
|
||||||
},
|
|
||||||
"delayed-stream@1.0.0": {
|
|
||||||
"delayed-stream": "private"
|
|
||||||
},
|
|
||||||
"ini@1.3.8": {
|
|
||||||
"ini": "private"
|
|
||||||
},
|
|
||||||
"proto-list@1.2.4": {
|
|
||||||
"proto-list": "private"
|
|
||||||
},
|
|
||||||
"ms@2.1.3": {
|
|
||||||
"ms": "private"
|
|
||||||
},
|
|
||||||
"@one-ini/wasm@0.1.1": {
|
|
||||||
"@one-ini/wasm": "private"
|
|
||||||
},
|
|
||||||
"commander@10.0.1": {
|
|
||||||
"commander": "private"
|
|
||||||
},
|
|
||||||
"minimatch@9.0.9": {
|
|
||||||
"minimatch": "private"
|
|
||||||
},
|
|
||||||
"semver@7.8.5": {
|
|
||||||
"semver": "private"
|
|
||||||
},
|
|
||||||
"es-errors@1.3.0": {
|
|
||||||
"es-errors": "private"
|
|
||||||
},
|
|
||||||
"get-intrinsic@1.3.0": {
|
|
||||||
"get-intrinsic": "private"
|
|
||||||
},
|
|
||||||
"has-tostringtag@1.0.2": {
|
|
||||||
"has-tostringtag": "private"
|
|
||||||
},
|
|
||||||
"@types/estree@1.0.9": {
|
|
||||||
"@types/estree": "private"
|
|
||||||
},
|
|
||||||
"foreground-child@3.3.1": {
|
|
||||||
"foreground-child": "private"
|
|
||||||
},
|
|
||||||
"jackspeak@3.4.3": {
|
|
||||||
"jackspeak": "private"
|
|
||||||
},
|
|
||||||
"minipass@7.1.3": {
|
|
||||||
"minipass": "private"
|
|
||||||
},
|
|
||||||
"package-json-from-dist@1.0.1": {
|
|
||||||
"package-json-from-dist": "private"
|
|
||||||
},
|
|
||||||
"path-scurry@1.11.1": {
|
|
||||||
"path-scurry": "private"
|
|
||||||
},
|
|
||||||
"function-bind@1.1.2": {
|
|
||||||
"function-bind": "private"
|
|
||||||
},
|
|
||||||
"mime-db@1.52.0": {
|
|
||||||
"mime-db": "private"
|
|
||||||
},
|
|
||||||
"abbrev@2.0.0": {
|
|
||||||
"abbrev": "private"
|
|
||||||
},
|
|
||||||
"confbox@0.1.8": {
|
|
||||||
"confbox": "private"
|
|
||||||
},
|
|
||||||
"exsolve@1.1.0": {
|
|
||||||
"exsolve": "private"
|
|
||||||
},
|
|
||||||
"js-tokens@9.0.1": {
|
|
||||||
"js-tokens": "private"
|
|
||||||
},
|
|
||||||
"tldts-core@7.4.9": {
|
|
||||||
"tldts-core": "private"
|
|
||||||
},
|
|
||||||
"punycode@2.3.1": {
|
|
||||||
"punycode": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/resolve-uri@3.1.2": {
|
|
||||||
"@jridgewell/resolve-uri": "private"
|
|
||||||
},
|
|
||||||
"cross-spawn@7.0.6": {
|
|
||||||
"cross-spawn": "private"
|
|
||||||
},
|
|
||||||
"signal-exit@4.1.0": {
|
|
||||||
"signal-exit": "private"
|
|
||||||
},
|
|
||||||
"call-bind-apply-helpers@1.0.2": {
|
|
||||||
"call-bind-apply-helpers": "private"
|
|
||||||
},
|
|
||||||
"es-define-property@1.0.1": {
|
|
||||||
"es-define-property": "private"
|
|
||||||
},
|
|
||||||
"es-object-atoms@1.1.2": {
|
|
||||||
"es-object-atoms": "private"
|
|
||||||
},
|
|
||||||
"get-proto@1.0.1": {
|
|
||||||
"get-proto": "private"
|
|
||||||
},
|
|
||||||
"gopd@1.2.0": {
|
|
||||||
"gopd": "private"
|
|
||||||
},
|
|
||||||
"has-symbols@1.1.0": {
|
|
||||||
"has-symbols": "private"
|
|
||||||
},
|
|
||||||
"math-intrinsics@1.1.0": {
|
|
||||||
"math-intrinsics": "private"
|
|
||||||
},
|
|
||||||
"@isaacs/cliui@8.0.2": {
|
|
||||||
"@isaacs/cliui": "private"
|
|
||||||
},
|
|
||||||
"@pkgjs/parseargs@0.11.0": {
|
|
||||||
"@pkgjs/parseargs": "private"
|
|
||||||
},
|
|
||||||
"brace-expansion@2.1.2": {
|
|
||||||
"brace-expansion": "private"
|
|
||||||
},
|
|
||||||
"string-width@5.1.2": {
|
|
||||||
"string-width": "private"
|
|
||||||
},
|
|
||||||
"string-width@4.2.3": {
|
|
||||||
"string-width-cjs": "private"
|
|
||||||
},
|
|
||||||
"strip-ansi@7.2.0": {
|
|
||||||
"strip-ansi": "private"
|
|
||||||
},
|
|
||||||
"strip-ansi@6.0.1": {
|
|
||||||
"strip-ansi-cjs": "private"
|
|
||||||
},
|
|
||||||
"wrap-ansi@8.1.0": {
|
|
||||||
"wrap-ansi": "private"
|
|
||||||
},
|
|
||||||
"wrap-ansi@7.0.0": {
|
|
||||||
"wrap-ansi-cjs": "private"
|
|
||||||
},
|
|
||||||
"balanced-match@1.0.2": {
|
|
||||||
"balanced-match": "private"
|
|
||||||
},
|
|
||||||
"path-key@3.1.1": {
|
|
||||||
"path-key": "private"
|
|
||||||
},
|
|
||||||
"shebang-command@2.0.0": {
|
|
||||||
"shebang-command": "private"
|
|
||||||
},
|
|
||||||
"which@2.0.2": {
|
|
||||||
"which": "private"
|
|
||||||
},
|
|
||||||
"dunder-proto@1.0.1": {
|
|
||||||
"dunder-proto": "private"
|
|
||||||
},
|
|
||||||
"shebang-regex@3.0.0": {
|
|
||||||
"shebang-regex": "private"
|
|
||||||
},
|
|
||||||
"emoji-regex@8.0.0": {
|
|
||||||
"emoji-regex": "private"
|
|
||||||
},
|
|
||||||
"is-fullwidth-code-point@3.0.0": {
|
|
||||||
"is-fullwidth-code-point": "private"
|
|
||||||
},
|
|
||||||
"eastasianwidth@0.2.0": {
|
|
||||||
"eastasianwidth": "private"
|
|
||||||
},
|
|
||||||
"ansi-regex@5.0.1": {
|
|
||||||
"ansi-regex": "private"
|
|
||||||
},
|
|
||||||
"isexe@2.0.0": {
|
|
||||||
"isexe": "private"
|
|
||||||
},
|
|
||||||
"ansi-styles@4.3.0": {
|
|
||||||
"ansi-styles": "private"
|
|
||||||
},
|
|
||||||
"color-convert@2.0.1": {
|
|
||||||
"color-convert": "private"
|
|
||||||
},
|
|
||||||
"color-name@1.1.4": {
|
|
||||||
"color-name": "private"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hoistPattern": [
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
"included": {
|
|
||||||
"dependencies": true,
|
|
||||||
"devDependencies": true,
|
|
||||||
"optionalDependencies": true
|
|
||||||
},
|
|
||||||
"injectedDeps": {},
|
|
||||||
"layoutVersion": 5,
|
|
||||||
"nodeLinker": "isolated",
|
|
||||||
"packageManager": "pnpm@11.17.0",
|
|
||||||
"pendingBuilds": [],
|
|
||||||
"publicHoistPattern": [],
|
|
||||||
"prunedAt": "Thu, 20 Aug 2026 06:47:32 GMT",
|
|
||||||
"registries": {
|
|
||||||
"default": "https://registry.npmjs.org/",
|
|
||||||
"@jsr": "https://npm.jsr.io/"
|
|
||||||
},
|
|
||||||
"skipped": [
|
|
||||||
"@emnapi/core@1.11.1",
|
|
||||||
"@emnapi/runtime@1.11.1",
|
|
||||||
"@emnapi/wasi-threads@1.2.2",
|
|
||||||
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)",
|
|
||||||
"@rolldown/binding-android-arm64@1.1.5",
|
|
||||||
"@rolldown/binding-darwin-arm64@1.1.5",
|
|
||||||
"@rolldown/binding-darwin-x64@1.1.5",
|
|
||||||
"@rolldown/binding-freebsd-x64@1.1.5",
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.1.5",
|
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-arm64-musl@1.1.5",
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-x64-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-x64-musl@1.1.5",
|
|
||||||
"@rolldown/binding-openharmony-arm64@1.1.5",
|
|
||||||
"@rolldown/binding-wasm32-wasi@1.1.5",
|
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.1.5",
|
|
||||||
"@tybys/wasm-util@0.10.3",
|
|
||||||
"fsevents@2.3.3",
|
|
||||||
"lightningcss-android-arm64@1.33.0",
|
|
||||||
"lightningcss-darwin-arm64@1.33.0",
|
|
||||||
"lightningcss-darwin-x64@1.33.0",
|
|
||||||
"lightningcss-freebsd-x64@1.33.0",
|
|
||||||
"lightningcss-linux-arm-gnueabihf@1.33.0",
|
|
||||||
"lightningcss-linux-arm64-gnu@1.33.0",
|
|
||||||
"lightningcss-linux-arm64-musl@1.33.0",
|
|
||||||
"lightningcss-linux-x64-gnu@1.33.0",
|
|
||||||
"lightningcss-linux-x64-musl@1.33.0",
|
|
||||||
"lightningcss-win32-arm64-msvc@1.33.0",
|
|
||||||
"tslib@2.8.1"
|
|
||||||
],
|
|
||||||
"storeDir": "C:\\Users\\BY-OY\\AppData\\Local\\pnpm\\store\\v11",
|
|
||||||
"virtualStoreDir": "C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global-dev\\apps\\admin-web\\node_modules\\.pnpm",
|
|
||||||
"virtualStoreDirMaxLength": 60
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"lastValidatedTimestamp": 1787208452958,
|
|
||||||
"projects": {},
|
|
||||||
"pnpmfiles": [],
|
|
||||||
"settings": {
|
|
||||||
"autoInstallPeers": true,
|
|
||||||
"dedupeDirectDeps": false,
|
|
||||||
"dedupeInjectedDeps": true,
|
|
||||||
"dedupePeerDependents": true,
|
|
||||||
"dedupePeers": false,
|
|
||||||
"dev": true,
|
|
||||||
"excludeLinksFromLockfile": false,
|
|
||||||
"hoistPattern": [
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
"hoistWorkspacePackages": true,
|
|
||||||
"injectWorkspacePackages": false,
|
|
||||||
"linkWorkspacePackages": false,
|
|
||||||
"minimumReleaseAge": 1440,
|
|
||||||
"minimumReleaseAgeIgnoreMissingTime": true,
|
|
||||||
"nodeLinker": "isolated",
|
|
||||||
"optional": true,
|
|
||||||
"peersSuffixMaxLength": 1000,
|
|
||||||
"preferWorkspacePackages": false,
|
|
||||||
"production": true,
|
|
||||||
"publicHoistPattern": []
|
|
||||||
},
|
|
||||||
"filteredInstall": false
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
|
|||||||
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
|
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
|
||||||
|
|
||||||
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
|
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
|
||||||
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
|
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
|
||||||
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
|
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
|
||||||
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
|
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe('AppLayout', () => {
|
|||||||
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
||||||
expect(wrapper.text()).toContain('消息管理')
|
expect(wrapper.text()).toContain('消息管理')
|
||||||
expect(wrapper.text()).toContain('模板管理')
|
expect(wrapper.text()).toContain('模板管理')
|
||||||
expect(wrapper.text()).not.toContain('我的消息')
|
expect(wrapper.text()).toContain('我的消息')
|
||||||
const notificationTrigger = wrapper.get('.notification-trigger')
|
const notificationTrigger = wrapper.get('.notification-trigger')
|
||||||
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const menuExpanded = computed(() => (
|
|||||||
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
||||||
))
|
))
|
||||||
const canOpenNotificationManagement = computed(() => (
|
const canOpenNotificationManagement = computed(() => (
|
||||||
access.can('platform:notification:task:list')
|
access.can('platform:notification:view')
|
||||||
|
|| access.can('platform:notification:task:list')
|
||||||
|| access.can('platform:notification:template:list')
|
|| access.can('platform:notification:template:list')
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -177,6 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
<Bell :size="18" /><span>消息中心</span>
|
<Bell :size="18" /><span>消息中心</span>
|
||||||
</button>
|
</button>
|
||||||
<template #dropdown><el-dropdown-menu>
|
<template #dropdown><el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
||||||
</el-dropdown-menu></template>
|
</el-dropdown-menu></template>
|
||||||
@@ -193,7 +195,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
<component
|
<component
|
||||||
:is="Component"
|
:is="Component"
|
||||||
v-if="renderedRoute.meta.keepAlive !== false"
|
v-if="renderedRoute.meta.keepAlive !== false"
|
||||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
|
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
|
||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
|
|||||||
|
|
||||||
it('registers message-center routes with the frozen IAM permissions', () => {
|
it('registers message-center routes with the frozen IAM permissions', () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
|
['notification-inbox', '/notifications', 'platform:notification:view'],
|
||||||
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
||||||
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
@@ -89,6 +89,8 @@ describe('admin auth session', () => {
|
|||||||
mocks.refreshHandler = null
|
mocks.refreshHandler = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
||||||
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
||||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||||
@@ -203,6 +205,32 @@ describe('admin auth session', () => {
|
|||||||
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuses a session rotated by another tab without refreshing it again', async () => {
|
||||||
|
const previousToken = token({ token_version: 1 })
|
||||||
|
const rotatedToken = token({ token_version: 2 })
|
||||||
|
localStorage.setItem('cc-admin-token', previousToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: {
|
||||||
|
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
|
||||||
|
localStorage.setItem('cc-admin-token', rotatedToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
|
||||||
|
return callback()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
await expect(auth.refreshSession()).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
expect(mocks.post).not.toHaveBeenCalled()
|
||||||
|
expect(auth.token).toBe(rotatedToken)
|
||||||
|
expect(auth.refreshToken).toBe('refresh-rotated')
|
||||||
|
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
|
||||||
|
})
|
||||||
|
|
||||||
it('does not let an in-flight refresh restore a session after logout', async () => {
|
it('does not let an in-flight refresh restore a session after logout', async () => {
|
||||||
localStorage.setItem('cc-admin-token', token())
|
localStorage.setItem('cc-admin-token', token())
|
||||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
|||||||
@@ -203,14 +203,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
observedRefreshToken,
|
observedRefreshToken,
|
||||||
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
||||||
reuseCurrentSession: async () => {
|
reuseCurrentSession: async () => {
|
||||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
applyStoredSession()
|
||||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
ensureCurrentGeneration(expectedGeneration)
|
||||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
|
||||||
'/auth/refresh',
|
|
||||||
{ refreshToken: storedRefreshToken },
|
|
||||||
)
|
|
||||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
|
||||||
},
|
},
|
||||||
refresh: async (currentRefreshToken) => {
|
refresh: async (currentRefreshToken) => {
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
locks: {
|
locks: {
|
||||||
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
|
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
|
||||||
currentRefreshToken = 'refresh-new'
|
currentRefreshToken = 'refresh-new'
|
||||||
return callback()
|
return callback()
|
||||||
}),
|
}),
|
||||||
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
it('performs the refresh when the token has not changed', async () => {
|
it('performs the refresh when the token has not changed', async () => {
|
||||||
const refresh = vi.fn(async () => 'access-new')
|
const refresh = vi.fn(async () => 'access-new')
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
|
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(coordinateCrossTabRefresh({
|
await expect(coordinateCrossTabRefresh({
|
||||||
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
|
|
||||||
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('aborts a refresh lock that cannot be acquired', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: {
|
||||||
|
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
|
||||||
|
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinateCrossTabRefresh({
|
||||||
|
lockName: 'admin-refresh',
|
||||||
|
lockTimeoutMs: 5,
|
||||||
|
observedRefreshToken: 'refresh-old',
|
||||||
|
currentRefreshToken: () => 'refresh-old',
|
||||||
|
reuseCurrentSession: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
export interface CrossTabRefreshOptions<T> {
|
export interface CrossTabRefreshOptions<T> {
|
||||||
lockName: string
|
lockName: string
|
||||||
|
lockTimeoutMs?: number
|
||||||
observedRefreshToken: string
|
observedRefreshToken: string
|
||||||
currentRefreshToken: () => string
|
currentRefreshToken: () => string
|
||||||
reuseCurrentSession: () => Promise<T>
|
reuseCurrentSession: () => Promise<T>
|
||||||
refresh: (refreshToken: string) => Promise<T>
|
refresh: (refreshToken: string) => Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CrossTabRefreshTimeoutError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('登录状态刷新等待超时,请重新登录')
|
||||||
|
this.name = 'CrossTabRefreshTimeoutError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const currentRefreshToken = options.currentRefreshToken()
|
const currentRefreshToken = options.currentRefreshToken()
|
||||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return navigator.locks.request(options.lockName, run)
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ describe('adminNavigation', () => {
|
|||||||
'订单列表',
|
'订单列表',
|
||||||
'订单测量',
|
'订单测量',
|
||||||
'测量数据审核',
|
'测量数据审核',
|
||||||
|
'预装清单',
|
||||||
'库位管理',
|
'库位管理',
|
||||||
|
'托盘管理',
|
||||||
'整柜业务',
|
'整柜业务',
|
||||||
])
|
])
|
||||||
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
|
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
|
||||||
@@ -45,7 +47,6 @@ describe('adminNavigation', () => {
|
|||||||
'订舱列表',
|
'订舱列表',
|
||||||
'拖车列表',
|
'拖车列表',
|
||||||
'货柜列表',
|
'货柜列表',
|
||||||
'预装清单',
|
|
||||||
'装柜清单',
|
'装柜清单',
|
||||||
])
|
])
|
||||||
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
||||||
@@ -249,8 +250,8 @@ describe('adminNavigation', () => {
|
|||||||
|
|
||||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/container-loading',
|
|
||||||
'/loading-plans',
|
'/loading-plans',
|
||||||
|
'/container-loading',
|
||||||
'/loading-lists',
|
'/loading-lists',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
ClipboardCheck,
|
|
||||||
ContactRound,
|
ContactRound,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
@@ -68,7 +67,9 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
{ 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-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: '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: '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: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -102,7 +103,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
{ 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: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
||||||
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
|
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
|
||||||
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
|
||||||
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
|
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order: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: '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: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
||||||
{ key: 'overseas-acceptance', title: '尾程流程验收', icon: ClipboardCheck, path: '/prototype/overseas-delivery/acceptance-workbench' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
|
|||||||
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
|
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
|
||||||
|
|
||||||
describe('admin nginx deployment config', () => {
|
describe('admin nginx deployment config', () => {
|
||||||
it('allows qualification uploads with multipart overhead through the API proxy', () => {
|
it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => {
|
||||||
expect(config).toContain('client_max_body_size 12m;')
|
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(2)
|
||||||
|
expect(config).not.toContain('client_max_body_size 12m;')
|
||||||
|
expect(config).not.toContain('client_max_body_size 0;')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prevents the application shell from retaining stale chunk references', () => {
|
it('prevents the application shell from retaining stale chunk references', () => {
|
||||||
@@ -36,8 +38,13 @@ describe('admin nginx deployment config', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
||||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
|
||||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "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/}"')
|
||||||
|
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
|
||||||
|
expect(config).toContain('proxy_set_header Host $proxy_host;')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('runs each deployment on its target node without controller-local SSH files', () => {
|
it('runs each deployment on its target node without controller-local SSH files', () => {
|
||||||
@@ -58,5 +65,6 @@ describe('admin nginx deployment config', () => {
|
|||||||
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
|
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
|
||||||
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
|
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
|
||||||
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
|
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
|
||||||
|
expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { http } from '@/shared/api/http'
|
||||||
|
import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
|
||||||
|
|
||||||
|
const customs = {
|
||||||
|
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
|
||||||
|
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
|
||||||
|
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
|
||||||
|
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
|
||||||
|
startedAt: null, completedAt: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('platform container customs API', () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => {
|
||||||
|
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs })
|
||||||
|
const result = await getContainerCustoms('2092438843630391402')
|
||||||
|
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/2092438843630391402')
|
||||||
|
expect(result).toMatchObject({ aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403' })
|
||||||
|
expect(result.inbounds[0]).toMatchObject({ inboundId: '2092438843630391404', orderId: '2092438843630391405', tenantId: '2092438843630391406' })
|
||||||
|
expect(result.documents[0]?.documentId).toBe('2092438843630391407')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends confirmed start and all-buy completion with optimistic version and idempotency', async () => {
|
||||||
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
|
||||||
|
await startContainerCustoms('2092438843630391402', 3)
|
||||||
|
await completeAllBuyContainerCustoms('2092438843630391402', 4)
|
||||||
|
expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/start', { expectedVersion: 3, confirm: true, remark: null }])
|
||||||
|
expect(post.mock.calls[1]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/complete-all-buy', { expectedVersion: 4, confirm: true, remark: null }])
|
||||||
|
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads one completion file with final-confirmation query parameters', async () => {
|
||||||
|
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
|
||||||
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
|
||||||
|
const completion = new File(['done'], 'done.pdf')
|
||||||
|
await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
|
||||||
|
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
|
||||||
|
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
|
||||||
|
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps source and completion downloads isolated on the long document id', async () => {
|
||||||
|
const source = new Blob(['source'])
|
||||||
|
const completion = new Blob(['completion'])
|
||||||
|
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(source).mockResolvedValueOnce(completion)
|
||||||
|
const documentId = '2092438843630391407'
|
||||||
|
|
||||||
|
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
|
||||||
|
await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion)
|
||||||
|
|
||||||
|
expect(get.mock.calls).toEqual([
|
||||||
|
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
|
||||||
|
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
|
||||||
|
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
|
||||||
|
|
||||||
|
export interface ContainerCustomsFile {
|
||||||
|
fileId: string
|
||||||
|
fileName: string
|
||||||
|
fileHash: string
|
||||||
|
sizeBytes: number
|
||||||
|
version: number
|
||||||
|
uploadedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerCustomsInbound {
|
||||||
|
inboundId: string
|
||||||
|
orderId: string
|
||||||
|
orderNo: string
|
||||||
|
inboundCode: string
|
||||||
|
tenantId: string
|
||||||
|
customerName: string
|
||||||
|
taxMode: ContainerCustomsTaxMode
|
||||||
|
covered: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerCustomsDocument {
|
||||||
|
documentId: string
|
||||||
|
tenantId: string
|
||||||
|
source: ContainerCustomsFile
|
||||||
|
inboundIds: string[]
|
||||||
|
inboundCodes: string[]
|
||||||
|
frozen: boolean
|
||||||
|
completion: ContainerCustomsFile | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerCustomsView {
|
||||||
|
aggregateId: string
|
||||||
|
loadingPlanId: string
|
||||||
|
containerBookingId: string
|
||||||
|
planNo: string
|
||||||
|
cabinetNo: string
|
||||||
|
containerNo: string | null
|
||||||
|
status: ContainerCustomsStatus
|
||||||
|
version: number
|
||||||
|
taxRefundCount: number
|
||||||
|
coveredTaxRefundCount: number
|
||||||
|
inbounds: ContainerCustomsInbound[]
|
||||||
|
documents: ContainerCustomsDocument[]
|
||||||
|
startedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
|
||||||
|
aggregateId: string | number
|
||||||
|
loadingPlanId: string | number
|
||||||
|
containerBookingId: string | number
|
||||||
|
version: string | number
|
||||||
|
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
|
||||||
|
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'completion'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = '/platform/v1/customs/loading-plans'
|
||||||
|
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||||
|
function file(value: ContainerCustomsFile | null): ContainerCustomsFile | null {
|
||||||
|
return value ? { ...value, fileId: String(value.fileId), sizeBytes: Number(value.sizeBytes), version: Number(value.version) } : null
|
||||||
|
}
|
||||||
|
function normalize(value: RawContainerCustomsView): ContainerCustomsView {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
|
||||||
|
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
|
||||||
|
documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, completion: file(item.completion) })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContainerCustoms(loadingPlanId: string) {
|
||||||
|
const response = await http.get<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
|
||||||
|
return normalize(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadContainerCustomsDocument(documentId: string) {
|
||||||
|
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadContainerCustomsCompletion(documentId: string) {
|
||||||
|
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
|
||||||
|
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
|
||||||
|
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
|
||||||
|
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
|
||||||
|
{ headers: { 'Idempotency-Key': operationId(`container-customs-${name}`) } },
|
||||||
|
)
|
||||||
|
return normalize(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startContainerCustoms(loadingPlanId: string, expectedVersion: number) {
|
||||||
|
return action(loadingPlanId, 'start', expectedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
|
||||||
|
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('file', fileValue)
|
||||||
|
const response = await http.post<ApiEnvelope<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
|
||||||
|
`${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
|
||||||
|
body,
|
||||||
|
{ params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
|
||||||
|
)
|
||||||
|
const result = unwrapApiResponse(response)
|
||||||
|
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) }
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
||||||
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
|
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||||
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
|
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||||
getCustomerContainerTransitSummary,
|
getCustomerContainerTransitSummary,
|
||||||
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
|
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
|
||||||
updateContainer, updateContainerTransport,
|
updateContainer,
|
||||||
} from './platformContainerLoading'
|
} from './platformContainerLoading'
|
||||||
|
|
||||||
vi.mock('@/shared/api/http', () => ({
|
vi.mock('@/shared/api/http', () => ({
|
||||||
@@ -42,14 +44,12 @@ describe('platform container loading API', () => {
|
|||||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses full-edit and transport whitelist request shapes', async () => {
|
it('uses the full-edit request shape without retaining the retired container transport endpoint', async () => {
|
||||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
|
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
|
||||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
|
|
||||||
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
|
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
|
||||||
await updateContainer('7', 2, data)
|
await updateContainer('7', 2, data)
|
||||||
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
|
|
||||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
|
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
|
||||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' })
|
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/api/platformContainerLoading.ts'), 'utf8')).not.toContain('/transport')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the frozen simulation create and ocean booking link contracts', async () => {
|
it('uses the frozen simulation create and ocean booking link contracts', async () => {
|
||||||
@@ -66,9 +66,9 @@ describe('platform container loading API', () => {
|
|||||||
it('normalizes every long id in candidates and plans', async () => {
|
it('normalizes every long id in candidates and plans', async () => {
|
||||||
vi.mocked(http.get)
|
vi.mocked(http.get)
|
||||||
.mockResolvedValueOnce({ code: '200', data: container })
|
.mockResolvedValueOnce({ code: '200', data: container })
|
||||||
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309' }] })
|
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] })
|
||||||
expect((await getContainer('9')).id).toBe(String(container.id))
|
expect((await getContainer('9')).id).toBe(String(container.id))
|
||||||
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309 }])
|
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }])
|
||||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } })
|
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,6 +90,21 @@ describe('platform container loading API', () => {
|
|||||||
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
|
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => {
|
||||||
|
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
|
||||||
|
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
||||||
|
await assignSmartLoadingGroup('10', '2093000000000000006', 5)
|
||||||
|
await removeSmartLoadingGroup('10', '2093000000000000001', 6)
|
||||||
|
expect(http.post).toHaveBeenCalledWith(
|
||||||
|
'/platform/v1/first-leg/smart-loading/plans/10/group-assignments',
|
||||||
|
{ inboundId: '2093000000000000006', expectedVersion: 5, operationId: expect.stringContaining('assign-coload-group-') },
|
||||||
|
)
|
||||||
|
expect(http.delete).toHaveBeenCalledWith(
|
||||||
|
'/platform/v1/first-leg/smart-loading/plans/10/group-assignments/2093000000000000001',
|
||||||
|
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-coload-group-') } },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses stable container id and versioned clearance document actions', async () => {
|
it('uses stable container id and versioned clearance document actions', async () => {
|
||||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
|
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
|
||||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
|
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
|
||||||
@@ -112,10 +127,10 @@ describe('platform container loading API', () => {
|
|||||||
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
|
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
|
||||||
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] },
|
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] },
|
||||||
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
|
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
|
||||||
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }],
|
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: 12, eventType: 'PUSH_PRELOAD', detail: '推送预装清单' }],
|
||||||
} })
|
} })
|
||||||
const result = await getContainerOverview('7')
|
const result = await getContainerOverview('7')
|
||||||
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }] })
|
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: '12' }] })
|
||||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
|
||||||
|
|
||||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
|
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
|
||||||
|
|||||||
@@ -60,7 +60,13 @@ function overview(raw: ContainerOverview): ContainerOverview {
|
|||||||
documents: (raw.documents || []).map(overviewDocument),
|
documents: (raw.documents || []).map(overviewDocument),
|
||||||
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
|
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
|
||||||
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
|
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
|
||||||
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
|
timeline: (raw.timeline || []).map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
id: optionalId(entry.id),
|
||||||
|
eventId: optionalId(entry.eventId),
|
||||||
|
idempotencyKey: optionalId(entry.idempotencyKey),
|
||||||
|
operatorId: optionalId(entry.operatorId),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +202,6 @@ export async function updateContainer(containerId: string, version: number, data
|
|||||||
return container(unwrapApiResponse(response))
|
return container(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) {
|
|
||||||
const response = await http.patch<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version })
|
|
||||||
return container(unwrapApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContainerClearance(containerId: string) {
|
export async function getContainerClearance(containerId: string) {
|
||||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
|
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
|
||||||
return document(unwrapApiResponse(response))
|
return document(unwrapApiResponse(response))
|
||||||
@@ -229,6 +230,10 @@ export async function listSmartLoadingCandidates(bookingId: string, warehouseId:
|
|||||||
return unwrapApiResponse(response).map((raw) => ({
|
return unwrapApiResponse(response).map((raw) => ({
|
||||||
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId),
|
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId),
|
||||||
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
|
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
|
||||||
|
coLoadGroupId: optionalId(raw.coLoadGroupId),
|
||||||
|
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
|
||||||
|
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
|
||||||
|
locked: raw.locked == null ? null : Boolean(raw.locked),
|
||||||
availablePackageCount: numberValue(raw.availablePackageCount),
|
availablePackageCount: numberValue(raw.availablePackageCount),
|
||||||
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
|
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
|
||||||
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
|
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
|
||||||
@@ -257,6 +262,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI
|
|||||||
return plan(unwrapApiResponse(response))
|
return plan(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assignSmartLoadingGroup(planId: string, inboundId: string, expectedVersion: number) {
|
||||||
|
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||||
|
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments`,
|
||||||
|
{ inboundId, expectedVersion, operationId: operationId('assign-coload-group') },
|
||||||
|
)
|
||||||
|
return plan(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
export async function recommendSmartLoading(planId: string, expectedVersion: number) {
|
export async function recommendSmartLoading(planId: string, expectedVersion: number) {
|
||||||
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
|
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
|
||||||
return plan(unwrapApiResponse(response))
|
return plan(unwrapApiResponse(response))
|
||||||
@@ -272,6 +285,14 @@ export async function removeSmartLoadingItem(planId: string, itemId: string, exp
|
|||||||
return plan(unwrapApiResponse(response))
|
return plan(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeSmartLoadingGroup(planId: string, groupId: string, expectedPlanVersion: number) {
|
||||||
|
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||||
|
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`,
|
||||||
|
{ data: { expectedPlanVersion, operationId: operationId('remove-coload-group') } },
|
||||||
|
)
|
||||||
|
return plan(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
|
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
|
||||||
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
|
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
|
||||||
return plan(unwrapApiResponse(response))
|
return plan(unwrapApiResponse(response))
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import type { ContainerCustomsStatus, ContainerCustomsView } from '../api/platformContainerCustoms'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
completeAllBuy: vi.fn(),
|
||||||
|
upload: vi.fn(),
|
||||||
|
downloadSource: vi.fn(),
|
||||||
|
downloadCompletion: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../api/platformContainerCustoms', () => ({
|
||||||
|
getContainerCustoms: api.get,
|
||||||
|
startContainerCustoms: api.start,
|
||||||
|
completeAllBuyContainerCustoms: api.completeAllBuy,
|
||||||
|
uploadContainerCustomsCompletion: api.upload,
|
||||||
|
downloadContainerCustomsDocument: api.downloadSource,
|
||||||
|
downloadContainerCustomsCompletion: api.downloadCompletion,
|
||||||
|
}))
|
||||||
|
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||||
|
|
||||||
|
import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
|
||||||
|
|
||||||
|
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
|
||||||
|
return {
|
||||||
|
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
|
||||||
|
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
|
||||||
|
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
|
||||||
|
documents: [{
|
||||||
|
documentId: '2092438843630391407', tenantId: '2092438843630391406',
|
||||||
|
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
|
||||||
|
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
|
||||||
|
completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
|
||||||
|
}],
|
||||||
|
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
|
||||||
|
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(status: ContainerCustomsStatus, canManage = true, hasCompletion = false) {
|
||||||
|
api.get.mockResolvedValue(customs(status, hasCompletion))
|
||||||
|
const wrapper = mount(ContainerCustomsPanel, {
|
||||||
|
props: { loadingPlanId: '2092438843630391402', canList: true, canManage, departureState: 'WAITING_OTHER_GATES' },
|
||||||
|
global: { plugins: [ElementPlus] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
|
||||||
|
return wrapper.findAll('button').map((button) => button.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContainerCustomsPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.downloadSource.mockResolvedValue(new Blob(['source']))
|
||||||
|
api.downloadCompletion.mockResolvedValue(new Blob(['completion']))
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
||||||
|
const wrapper = await render('READY_TO_START')
|
||||||
|
expect(wrapper.text()).toContain('开始报关')
|
||||||
|
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||||
|
expect(wrapper.find('[aria-label="下载源资料 客户退税资料.pdf"]').exists()).toBe(true)
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
|
||||||
|
let wrapper = await render('DECLARING', true, false)
|
||||||
|
expect(wrapper.text()).toContain('上传完成文件')
|
||||||
|
expect(wrapper.text()).toContain('提交')
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
wrapper = await render('DECLARING', true, true)
|
||||||
|
expect(wrapper.text()).toContain('覆盖完成文件')
|
||||||
|
expect(wrapper.text()).toContain('报关完成文件.pdf')
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
wrapper = await render('DECLARING', false, true)
|
||||||
|
expect(wrapper.text()).toContain('只读')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
|
||||||
|
const wrapper = await render('COMPLETED', true, true)
|
||||||
|
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||||
|
expect(wrapper.text()).toContain('报关完成文件.pdf')
|
||||||
|
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
|
||||||
|
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes source and completion downloads independently by the long document id and original names', async () => {
|
||||||
|
const wrapper = await render('COMPLETED', false, true)
|
||||||
|
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
|
||||||
|
expect(api.downloadCompletion).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await wrapper.get('[aria-label="下载完成文件 报关完成文件.pdf"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
|
||||||
|
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves backend error code, request id and trace id when one download fails', async () => {
|
||||||
|
api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download'))
|
||||||
|
const wrapper = await render('COMPLETED', false, true)
|
||||||
|
|
||||||
|
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('文件不可用')
|
||||||
|
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
|
||||||
|
expect(wrapper.text()).toContain('request-customs-download')
|
||||||
|
expect(wrapper.text()).toContain('trace-customs-download')
|
||||||
|
expect(api.downloadCompletion).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { Download, FileUp } from '@lucide/vue'
|
||||||
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||||
|
import { createPlatformExportTask } from '@/modules/downloads'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||||
|
import {
|
||||||
|
completeAllBuyContainerCustoms,
|
||||||
|
downloadContainerCustomsCompletion,
|
||||||
|
downloadContainerCustomsDocument,
|
||||||
|
getContainerCustoms,
|
||||||
|
startContainerCustoms,
|
||||||
|
uploadContainerCustomsCompletion,
|
||||||
|
type ContainerCustomsDocument,
|
||||||
|
type ContainerCustomsStatus,
|
||||||
|
type ContainerCustomsView,
|
||||||
|
} from '../api/platformContainerCustoms'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
loadingPlanId: string
|
||||||
|
canList: boolean
|
||||||
|
canManage: boolean
|
||||||
|
departureState: 'DEPARTED' | 'WAITING_OTHER_GATES'
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ refreshed: [] }>()
|
||||||
|
|
||||||
|
const customs = ref<ContainerCustomsView | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const actionError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const downloadingKey = ref('')
|
||||||
|
const completionFiles = reactive<Record<string, File | null>>({})
|
||||||
|
|
||||||
|
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
|
||||||
|
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
|
||||||
|
const allBuy = computed(() => customs.value?.taxRefundCount === 0)
|
||||||
|
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
|
||||||
|
const statusText: Record<ContainerCustomsStatus, string> = {
|
||||||
|
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentCustomers(document: ContainerCustomsDocument) {
|
||||||
|
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
|
||||||
|
}
|
||||||
|
function selectCompletion(documentId: string, upload: UploadFile) {
|
||||||
|
if (!props.canManage || customs.value?.status !== 'DECLARING') return
|
||||||
|
completionFiles[documentId] = upload.raw || null
|
||||||
|
actionError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.canList || !props.loadingPlanId) return
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try { customs.value = await getContainerCustoms(props.loadingPlanId) }
|
||||||
|
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('开始后将冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
|
||||||
|
} catch { return }
|
||||||
|
actionLoading.value = true; actionError.value = null
|
||||||
|
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
||||||
|
catch (error) { actionError.value = getApiErrorDetails(error, '开始整柜报关失败') }
|
||||||
|
finally { actionLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCompletion(document: ContainerCustomsDocument) {
|
||||||
|
if (!props.canManage || customs.value?.status !== 'DECLARING') return
|
||||||
|
const selected = completionFiles[document.documentId]
|
||||||
|
if (!selected) { ElMessage.warning('请选择对应完成文件'); return }
|
||||||
|
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
|
||||||
|
if (finalConfirmation) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('这是最后一份待上传完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
actionLoading.value = true; actionError.value = null
|
||||||
|
try {
|
||||||
|
await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
|
||||||
|
completionFiles[document.documentId] = null
|
||||||
|
await load()
|
||||||
|
emit('refreshed')
|
||||||
|
ElMessage.success(finalConfirmation ? '完成文件已上传,整柜报关已完成' : '完成文件已上传')
|
||||||
|
} catch (error) { actionError.value = getApiErrorDetails(error, '完成文件上传失败') }
|
||||||
|
finally { actionLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = fileName
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'completion') {
|
||||||
|
const target = kind === 'source' ? document.source : document.completion
|
||||||
|
if (!target) return
|
||||||
|
const key = `${kind}:${document.documentId}`
|
||||||
|
downloadingKey.value = key
|
||||||
|
actionError.value = null
|
||||||
|
try {
|
||||||
|
const blob = kind === 'source'
|
||||||
|
? await downloadContainerCustomsDocument(document.documentId)
|
||||||
|
: await downloadContainerCustomsCompletion(document.documentId)
|
||||||
|
saveBlob(blob, target.fileName)
|
||||||
|
ElMessage.success(`${kind === 'source' ? '源资料' : '完成文件'}下载已开始`)
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '源资料' : '完成文件'}下载失败`)
|
||||||
|
} finally {
|
||||||
|
downloadingKey.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeAllBuy() {
|
||||||
|
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
|
||||||
|
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { type: 'warning', confirmButtonText: '确认完成' }) }
|
||||||
|
catch { return }
|
||||||
|
actionLoading.value = true; actionError.value = null
|
||||||
|
try {
|
||||||
|
customs.value = await completeAllBuyContainerCustoms(props.loadingPlanId, customs.value.version)
|
||||||
|
emit('refreshed')
|
||||||
|
ElMessage.success('全买单柜报关已完成')
|
||||||
|
} catch (error) { actionError.value = getApiErrorDetails(error, '完成全买单柜报关失败') }
|
||||||
|
finally { actionLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAllBuy() {
|
||||||
|
actionLoading.value = true; actionError.value = null
|
||||||
|
try {
|
||||||
|
const task = await createPlatformExportTask({
|
||||||
|
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || '柜级明细'}`,
|
||||||
|
parameters: { loadingPlanId: String(props.loadingPlanId) },
|
||||||
|
})
|
||||||
|
ElMessage.success(`买单报关导出任务已创建:${task.taskNo}`)
|
||||||
|
} catch (error) { actionError.value = getApiErrorDetails(error, '买单报关导出任务创建失败') }
|
||||||
|
finally { actionLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.loadingPlanId, () => { void load() })
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="container-customs surface-panel" aria-label="整柜报关">
|
||||||
|
<OperationErrorToast :details="actionError" />
|
||||||
|
<header class="customs-header">
|
||||||
|
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
|
||||||
|
<div v-if="customs" class="customs-actions">
|
||||||
|
<el-tag :type="customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ statusText[customs.status] }}</el-tag>
|
||||||
|
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
|
||||||
|
<el-button v-if="canManage && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
|
||||||
|
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div v-if="actionError" class="action-error" role="alert"><span>{{ actionError.message }}</span><ApiErrorMeta :details="actionError" /></div>
|
||||||
|
<LoadErrorState v-if="loadError" compact title="整柜报关加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||||
|
<div v-else-if="loading"><el-skeleton :rows="3" animated /></div>
|
||||||
|
<template v-else-if="customs">
|
||||||
|
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
|
||||||
|
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||||
|
<h3>缺资料清单</h3>
|
||||||
|
<table class="compact-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
|
||||||
|
</div>
|
||||||
|
<div v-if="customs.documents.length" class="compact-table-wrap">
|
||||||
|
<h3>冻结资料与完成文件</h3>
|
||||||
|
<table class="compact-table document-table"><thead><tr><th>操作</th><th>源资料</th><th>完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
|
||||||
|
<tr v-for="document in customs.documents" :key="document.documentId">
|
||||||
|
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖完成文件' : '上传完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||||
|
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载源资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
|
||||||
|
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
|
||||||
|
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>资料 v{{ document.source.version }}<span v-if="document.completion"> / 完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传完成文件' : '待上传' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件' }}</el-tag></div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:230px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
|
||||||
|
</style>
|
||||||
@@ -47,11 +47,11 @@ describe('ContainerOverviewPanel', () => {
|
|||||||
expect(wrapper.text()).not.toContain('9007199254740999')
|
expect(wrapper.text()).not.toContain('9007199254740999')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders four compact document table rows with the operation column first', () => {
|
it('renders three compact non-customs document rows with the operation column first', () => {
|
||||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||||
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
|
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
|
||||||
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
|
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
|
||||||
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(4)
|
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
|
||||||
expect(wrapper.findAll('.document-item')).toHaveLength(0)
|
expect(wrapper.findAll('.document-item')).toHaveLength(0)
|
||||||
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
|
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
|
||||||
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
|
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
|
||||||
@@ -158,11 +158,4 @@ describe('ContainerOverviewPanel', () => {
|
|||||||
expect(wrapper.text()).not.toContain('Authorization')
|
expect(wrapper.text()).not.toContain('Authorization')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps a completed customs document labelled as an attachment, not formal customs release', () => {
|
|
||||||
const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' }
|
|
||||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
||||||
expect(wrapper.text()).toContain('国内出口报关附件')
|
|
||||||
expect(wrapper.text()).not.toContain('已完成报关')
|
|
||||||
expect(wrapper.text()).not.toContain('已放行')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
|
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
|
||||||
import { Check, Download, FileUp, TriangleAlert } from '@lucide/vue'
|
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
|
||||||
import { getApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails } from '@/shared/api/http'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
||||||
@@ -10,36 +10,31 @@ const props = defineProps<{
|
|||||||
overview: ContainerOverview
|
overview: ContainerOverview
|
||||||
canManageDocuments: boolean
|
canManageDocuments: boolean
|
||||||
canManageCustoms: boolean
|
canManageCustoms: boolean
|
||||||
canDownloadCustoms?: boolean
|
|
||||||
canManageException: boolean
|
canManageException: boolean
|
||||||
documentBusy?: boolean
|
documentBusy?: boolean
|
||||||
customsCompleting?: boolean
|
sections?: Array<'exception' | 'documents' | 'pda'>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
uploadDocument: [documentType: string]
|
uploadDocument: [documentType: string]
|
||||||
downloadDocument: [document: ContainerOverviewDocument]
|
downloadDocument: [document: ContainerOverviewDocument]
|
||||||
completeCustoms: [document: ContainerOverviewDocument]
|
|
||||||
manageException: []
|
manageException: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const documentTypes = [
|
const documentTypes = [
|
||||||
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
|
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
|
||||||
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
|
|
||||||
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
|
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
|
||||||
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
|
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
|
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
|
||||||
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
||||||
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
|
|
||||||
function canDownloadDocument(documentType: string) {
|
function canDownloadDocument(documentType: string) {
|
||||||
return containerDownloadableTypes.has(documentType)
|
return containerDownloadableTypes.has(documentType)
|
||||||
|| (documentType === 'CUSTOMS_DECLARATION' && props.canDownloadCustoms)
|
|
||||||
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
|
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
|
||||||
}
|
}
|
||||||
function canUploadDocument(documentType: string) {
|
function canUploadDocument(documentType: string) {
|
||||||
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
|
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
|
||||||
}
|
}
|
||||||
function hasDocumentAction(documentType: string) {
|
function hasDocumentAction(documentType: string) {
|
||||||
const document = documentsByType.value.get(documentType)
|
const document = documentsByType.value.get(documentType)
|
||||||
@@ -47,9 +42,7 @@ function hasDocumentAction(documentType: string) {
|
|||||||
}
|
}
|
||||||
function documentStatusText(documentType: string) {
|
function documentStatusText(documentType: string) {
|
||||||
const document = documentsByType.value.get(documentType)
|
const document = documentsByType.value.get(documentType)
|
||||||
if (documentType !== 'CUSTOMS_DECLARATION') return document ? formatTime(document.uploadedAt) : '等待上传'
|
return document ? formatTime(document.uploadedAt) : '等待上传'
|
||||||
if (!document) return '第一步上传附件,第二步确认报关完成'
|
|
||||||
return document.completed ? '已确认报关完成' : '附件已上传,待确认报关完成'
|
|
||||||
}
|
}
|
||||||
function documentStatusLabel(documentType: string) {
|
function documentStatusLabel(documentType: string) {
|
||||||
const document = documentsByType.value.get(documentType)
|
const document = documentsByType.value.get(documentType)
|
||||||
@@ -66,7 +59,10 @@ const pdaProgress = computed(() => {
|
|||||||
if (!pda || pda.itemCount <= 0) return 0
|
if (!pda || pda.itemCount <= 0) return 0
|
||||||
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
|
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
|
||||||
})
|
})
|
||||||
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
|
const visibleSections = computed(() => new Set(props.sections || ['exception', 'documents', 'pda']))
|
||||||
|
const activePhotos = computed(() => visibleSections.value.has('pda')
|
||||||
|
? (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath)
|
||||||
|
: [])
|
||||||
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
|
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
|
||||||
let photoGeneration = 0
|
let photoGeneration = 0
|
||||||
const workflowStatusLabels: Record<string, string> = {
|
const workflowStatusLabels: Record<string, string> = {
|
||||||
@@ -118,19 +114,19 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="overview-panel">
|
<div class="overview-panel">
|
||||||
<section class="overview-section exception-management" aria-label="异常管理">
|
<section v-if="visibleSections.has('exception')" class="overview-section exception-management" aria-label="异常管理">
|
||||||
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
|
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
|
||||||
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
|
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="overview-section" aria-label="货柜业务资料">
|
<section v-if="visibleSections.has('documents')" class="overview-section" aria-label="货柜业务资料">
|
||||||
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
||||||
<div class="document-table-wrap">
|
<div class="document-table-wrap">
|
||||||
<table class="document-table">
|
<table class="document-table">
|
||||||
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
|
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
|
||||||
<tbody><tr v-for="item in documentTypes" :key="item.type">
|
<tbody><tr v-for="item in documentTypes" :key="item.type">
|
||||||
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><el-button v-if="item.type === 'CUSTOMS_DECLARATION' && canManageCustoms && documentsByType.get(item.type) && !documentsByType.get(item.type)?.completed" size="small" type="primary" :loading="customsCompleting" @click="emit('completeCustoms', documentsByType.get(item.type)!)"><Check :size="15" />确认报关完成</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||||
<td><strong>{{ item.label }}</strong><small v-if="item.type === 'CUSTOMS_DECLARATION'">{{ documentStatusText(item.type) }}</small></td>
|
<td><strong>{{ item.label }}</strong></td>
|
||||||
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
|
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
|
||||||
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
|
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
|
||||||
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
|
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
|
||||||
@@ -140,7 +136,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="overview-section" aria-label="PDA作业摘要">
|
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
|
||||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
|
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
|
||||||
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
||||||
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||||
@@ -151,7 +147,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.overview-panel { display: grid; gap: 12px; }.overview-section:nth-child(3){order:1}.overview-section:nth-child(2){order:2}.overview-section:nth-child(1){order:3}
|
.overview-panel { display: grid; gap: 12px; }
|
||||||
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
|
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||||
|
|||||||
@@ -1,427 +1,62 @@
|
|||||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
import { readFileSync } from 'node:fs'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { resolve } from 'node:path'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import ContainerDetailView from './ContainerDetailView.vue'
|
|
||||||
import { downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
|
||||||
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
|
||||||
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
|
||||||
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
|
|
||||||
import { ApiResponseError } from '@/shared/api/http'
|
|
||||||
|
|
||||||
const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } }))
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
|
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
|
||||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
|
|
||||||
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn(), getLoadingDeclaration: vi.fn() }))
|
|
||||||
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
|
|
||||||
vi.mock('../api/platformContainerLoading', () => ({
|
|
||||||
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(),
|
|
||||||
downloadContainerClearance: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
|
describe('ContainerDetailView customs integration', () => {
|
||||||
const overview = {
|
it('renders the requested detail sections in the confirmed business order', () => {
|
||||||
container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
|
const headings = [
|
||||||
loadingPlanId: '2092438843630391401', pda: null,
|
'container-information-heading">货柜信息',
|
||||||
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
|
'container-voyage-heading">船名航次信息',
|
||||||
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
|
'container-customs-heading">报关清关信息',
|
||||||
}
|
'container-receiving-heading">签收信息',
|
||||||
const outboundDetail = {
|
'container-fees-heading">费用明细',
|
||||||
aggregate: {
|
]
|
||||||
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
|
let previous = -1
|
||||||
loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
|
for (const heading of headings) {
|
||||||
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
|
const current = source.indexOf(heading)
|
||||||
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
|
expect(current, heading).toBeGreaterThan(previous)
|
||||||
},
|
previous = current
|
||||||
timeline: [],
|
}
|
||||||
} as ContainerOutboundDispatchDetail
|
|
||||||
|
|
||||||
function mountView(customs = true, outbound = true) {
|
|
||||||
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|
|
||||||
|| (customs && permission === 'platform:firstleg:customs:manage')
|
|
||||||
|| permission === 'platform:firstleg:loading-list:list'
|
|
||||||
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
|
|
||||||
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
|
|
||||||
}
|
|
||||||
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! }
|
|
||||||
function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
|
|
||||||
|
|
||||||
describe('ContainerDetailView', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') })
|
|
||||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
|
||||||
carrierMounts.value = 0
|
|
||||||
vi.mocked(getContainer).mockResolvedValue(detail)
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
|
||||||
vi.mocked(getContainerClearance).mockResolvedValue(null)
|
|
||||||
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never)
|
|
||||||
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
|
|
||||||
vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs']))
|
|
||||||
vi.mocked(downloadContainerClearance).mockResolvedValue(new Blob(['clearance']))
|
|
||||||
vi.mocked(getLoadingDeclaration).mockResolvedValue(null)
|
|
||||||
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
|
|
||||||
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
|
|
||||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
|
|
||||||
vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
|
it('reuses the current schedule, customs, receipt and container-fee capabilities', () => {
|
||||||
const wrapper = mountView(); await flushPromises()
|
expect(source).toContain('<CarrierScheduleQueryPanel')
|
||||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
expect(source).toContain(':sections="[\'documents\']"')
|
||||||
expect(wrapper.text()).toContain('国内出口报关附件')
|
expect(source).toContain(':sections="[\'pda\']"')
|
||||||
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
|
expect(source).toContain('<ContainerFeesPanel')
|
||||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
expect(source).toContain('getConfirmedLoadingList')
|
||||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument()
|
expect(source).toContain('getPreloadPlan')
|
||||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
|
|
||||||
expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a readable error when the container has no loading plan', async () => {
|
it('mounts the new container customs panel only after loading completes', () => {
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
|
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
|
||||||
const wrapper = mountView(); await flushPromises()
|
expect(source).toContain('<ContainerCustomsPanel')
|
||||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
expect(source).toContain(':loading-plan-id="overview.loadingPlanId"')
|
||||||
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
expect(source).toContain(':can-manage="canManageCustoms"')
|
||||||
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
|
it('allows the overview panel to render only the section owned by each detail group', () => {
|
||||||
const wrapper = mountView(false); await flushPromises()
|
expect(overviewSource).toContain("sections?: Array<'exception' | 'documents' | 'pda'>")
|
||||||
expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载'])
|
expect(overviewSource).toContain("visibleSections.has('exception')")
|
||||||
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
|
expect(overviewSource).toContain("visibleSections.has('documents')")
|
||||||
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
|
expect(overviewSource).toContain("visibleSections.has('pda')")
|
||||||
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps destination clearance on its independent container clearance API', async () => {
|
it('removes the retired loading-list customs UI and calls', () => {
|
||||||
const wrapper = mountView(); await flushPromises()
|
for (const retired of ['getLoadingDeclaration', 'uploadLoadingDeclaration', 'completeLoadingDeclaration', 'downloadLoadingDeclaration', 'CUSTOMS_DECLARATION']) {
|
||||||
await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
|
expect(source).not.toContain(retired)
|
||||||
expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
|
expect(overviewSource).not.toContain(retired)
|
||||||
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
|
}
|
||||||
wrapper.unmount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => {
|
it('offers the released-booking link workflow only for an eligible unlinked simulation container', () => {
|
||||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
expect(source).toContain("detail.value?.sourceType === 'SIMULATION'")
|
||||||
const wrapper = mountView(); await flushPromises()
|
expect(source).toContain("!detail.value.oceanBookingId")
|
||||||
await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises()
|
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)")
|
||||||
expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502')
|
expect(source).toContain('关联已放舱订舱')
|
||||||
expect(downloadContainerDocument).not.toHaveBeenCalled()
|
expect(source).toContain("query: { linkContainerId: detail.value.id }")
|
||||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
|
||||||
expect(click).toHaveBeenCalledOnce()
|
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalled()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps customs download after completion while hiding update and completion actions', async () => {
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
const row = documentArticle(wrapper, '国内出口报关附件')
|
|
||||||
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
|
|
||||||
expect(row.text()).toContain('已完成')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => {
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable'))
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
|
||||||
const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' })
|
|
||||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
|
||||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
|
|
||||||
const row = documentArticle(wrapper, '国内出口报关附件')
|
|
||||||
expect(row.text()).toContain('customs-new.pdf')
|
|
||||||
expect(row.text()).toContain('操作员')
|
|
||||||
expect(row.text()).toContain('2026-08-26 12:00')
|
|
||||||
expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果')
|
|
||||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refreshes a hidden customs version after A0409 and preserves error evidence without auto retry', async () => {
|
|
||||||
const hidden = { id: '2092438843630392501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630392502', fileName: 'server-customs.pdf', fileHash: 'server-hash', uploadedBy: '2', uploadedByName: '服务端操作员', uploadedAt: '2026-08-26T12:30:00', completedBy: null, completedByName: null, completedAt: null, version: 7 }
|
|
||||||
const withoutCustoms = { ...overview, documents: [] }
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValue(withoutCustoms as never)
|
|
||||||
vi.mocked(getLoadingDeclaration).mockResolvedValueOnce(hidden as never)
|
|
||||||
vi.mocked(uploadLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-upload-409', 'trace-upload-409'))
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
await documentButton(wrapper, '国内出口报关附件', '上传附件').trigger('click'); await flushPromises()
|
|
||||||
const file = new File(['next'], 'next.pdf', { type: 'application/pdf' })
|
|
||||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
|
||||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
|
|
||||||
expect(uploadLoadingDeclaration).toHaveBeenCalledOnce()
|
|
||||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 0, '上传国内出口报关附件')
|
|
||||||
expect(getLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002')
|
|
||||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('server-customs.pdf')
|
|
||||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('服务端操作员')
|
|
||||||
expect(wrapper.text()).toContain('A0409')
|
|
||||||
expect(wrapper.text()).toContain('request-upload-409')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the independent destination document version and immediately replaces the table row after update', async () => {
|
|
||||||
const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
|
|
||||||
const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
|
|
||||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
|
|
||||||
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
|
||||||
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
|
|
||||||
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file })
|
|
||||||
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises()
|
|
||||||
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料')
|
|
||||||
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => {
|
|
||||||
const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true }
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never)
|
|
||||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
const row = documentArticle(wrapper, '目的港清关资料')
|
|
||||||
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
|
|
||||||
await documentButton(wrapper, '目的港清关资料', '下载').trigger('click'); await flushPromises()
|
|
||||||
expect(downloadContainerClearance).toHaveBeenCalledWith('2092438843630391297', '2092438843630393502')
|
|
||||||
expect(downloadLoadingDeclaration).not.toHaveBeenCalled()
|
|
||||||
expect(downloadContainerDocument).not.toHaveBeenCalled()
|
|
||||||
expect(click).toHaveBeenCalledOnce()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => {
|
|
||||||
const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
|
|
||||||
const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 }
|
|
||||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never)
|
|
||||||
vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
|
||||||
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
|
|
||||||
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file })
|
|
||||||
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises()
|
|
||||||
expect(uploadContainerClearance).toHaveBeenCalledOnce()
|
|
||||||
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
|
|
||||||
expect(wrapper.text()).toContain('A0409')
|
|
||||||
expect(wrapper.text()).toContain('request-clearance-409')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => {
|
|
||||||
const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) }
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never)
|
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
const customs = documentArticle(wrapper, '国内出口报关附件')
|
|
||||||
expect(customs.text()).toContain('附件已上传,待确认报关完成')
|
|
||||||
expect(customs.text()).toContain('更新附件')
|
|
||||||
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
|
|
||||||
expect(completeLoadingDeclaration).toHaveBeenCalledOnce()
|
|
||||||
expect(completeLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', 5, '货柜详情确认国内出口报关完成')
|
|
||||||
expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
|
|
||||||
expect(getContainerOverview).toHaveBeenCalledTimes(2)
|
|
||||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
|
|
||||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('已确认报关完成')
|
|
||||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows customs completion only for an existing unfinished attachment with manage permission', async () => {
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [] } as never)
|
|
||||||
let wrapper = mountView(); await flushPromises()
|
|
||||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('第一步上传附件,第二步确认报关完成')
|
|
||||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
|
||||||
wrapper.unmount()
|
|
||||||
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
|
|
||||||
wrapper = mountView(); await flushPromises()
|
|
||||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
|
||||||
wrapper.unmount()
|
|
||||||
|
|
||||||
wrapper = mountView(false); await flushPromises()
|
|
||||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a readable completion error without a loading plan and does not call the API', async () => {
|
|
||||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
|
|
||||||
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法确认报关完成')
|
|
||||||
expect(completeLoadingDeclaration).not.toHaveBeenCalled()
|
|
||||||
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prevents repeated customs completion and preserves A0409 evidence after refreshing facts', async () => {
|
|
||||||
let releasePrompt!: () => void
|
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise<void>((resolve) => { releasePrompt = resolve }) as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
const button = documentButton(wrapper, '国内出口报关附件', '确认报关完成')
|
|
||||||
await button.trigger('click'); await button.trigger('click')
|
|
||||||
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
|
||||||
releasePrompt(); await flushPromises()
|
|
||||||
vi.mocked(completeLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('数据已变更', 'A0409', undefined, undefined, 'request-customs-409', 'trace-customs-409'))
|
|
||||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({ action: 'confirm' } as never)
|
|
||||||
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
|
|
||||||
expect(completeLoadingDeclaration).toHaveBeenCalledTimes(2)
|
|
||||||
expect(wrapper.text()).toContain('A0409')
|
|
||||||
expect(wrapper.text()).toContain('request-customs-409')
|
|
||||||
expect(getContainerOverview).toHaveBeenCalledTimes(3)
|
|
||||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(3)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('loads and displays the whole-container gate without querying an inbound id', async () => {
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
|
|
||||||
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
|
|
||||||
expect(wrapper.text()).toContain('国内出仓门禁')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
|
|
||||||
expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成')
|
|
||||||
await wrapper.find('.gate-summary button').trigger('click')
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('报关协同完成')
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码')
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('IN-1')
|
|
||||||
expect(wrapper.text()).toContain('确认离仓')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
|
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
|
|
||||||
expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
|
|
||||||
expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
|
|
||||||
expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
|
|
||||||
expect(getContainerOverview).toHaveBeenCalledTimes(2)
|
|
||||||
expect(carrierMounts.value).toBe(2)
|
|
||||||
expect(wrapper.text()).toContain('已确认离仓')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prevents a repeated confirmation wave while the first prompt is open', async () => {
|
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
const button = wrapper.find('.outbound-panel__actions button')
|
|
||||||
await button.trigger('click')
|
|
||||||
await button.trigger('click')
|
|
||||||
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
|
||||||
expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides the outbound gate and never calls its API without view permission', async () => {
|
|
||||||
const wrapper = mountView(true, false); await flushPromises()
|
|
||||||
expect(wrapper.text()).not.toContain('国内出仓门禁')
|
|
||||||
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
|
|
||||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
|
||||||
...outboundDetail,
|
|
||||||
aggregate: {
|
|
||||||
...outboundDetail.aggregate,
|
|
||||||
gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }] },
|
|
||||||
},
|
|
||||||
} as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
expect(wrapper.text()).toContain('报关协同完成')
|
|
||||||
expect(wrapper.text()).toContain('MISSING')
|
|
||||||
expect(wrapper.text()).toContain('COMPLETED')
|
|
||||||
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => {
|
|
||||||
const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }
|
|
||||||
const passed = { code: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }
|
|
||||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
|
||||||
...outboundDetail,
|
|
||||||
aggregate: {
|
|
||||||
...outboundDetail.aggregate,
|
|
||||||
gate: {
|
|
||||||
passed: false,
|
|
||||||
checkedAt: '2026-08-26T12:00:00',
|
|
||||||
inbounds: [
|
|
||||||
{ inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
|
|
||||||
{ inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('阻断 1')
|
|
||||||
expect(wrapper.findAll('.gate-row')).toHaveLength(1)
|
|
||||||
expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码')
|
|
||||||
expect(wrapper.find('.gate-row').text()).toContain('IN-101')
|
|
||||||
expect(wrapper.find('.gate-row').text()).toContain('IN-102')
|
|
||||||
await wrapper.find('.gate-summary button').trigger('click')
|
|
||||||
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
|
|
||||||
expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps different facts for the same gate code separate and labels the affected inbound', async () => {
|
|
||||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
|
||||||
...outboundDetail,
|
|
||||||
aggregate: {
|
|
||||||
...outboundDetail.aggregate,
|
|
||||||
gate: {
|
|
||||||
passed: false,
|
|
||||||
checkedAt: '2026-08-26T12:00:00',
|
|
||||||
inbounds: [
|
|
||||||
{ inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } },
|
|
||||||
{ inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } },
|
|
||||||
{ inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('MISSING')
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING')
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('PENDING')
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING')
|
|
||||||
await wrapper.find('.gate-summary button').trigger('click')
|
|
||||||
expect(wrapper.findAll('.gate-row')).toHaveLength(3)
|
|
||||||
expect(wrapper.find('.gate-list').text()).toContain('IN-DONE')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps truck and sailing schedule as reference only and never counts legacy reference gates as blocking', async () => {
|
|
||||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
|
||||||
...outboundDetail,
|
|
||||||
aggregate: {
|
|
||||||
...outboundDetail.aggregate,
|
|
||||||
truckAppointmentStatus: 'ARRIVED',
|
|
||||||
gate: {
|
|
||||||
passed: false,
|
|
||||||
checkedAt: '2026-08-26T12:00:00',
|
|
||||||
inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [
|
|
||||||
{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' },
|
|
||||||
{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' },
|
|
||||||
{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' },
|
|
||||||
] } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never)
|
|
||||||
const wrapper = mountView(); await flushPromises()
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
|
|
||||||
expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓')
|
|
||||||
expect(wrapper.text()).not.toContain('关联拖车已离仓')
|
|
||||||
expect(wrapper.text()).not.toContain('船期有效且未过期')
|
|
||||||
expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓')
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
import { ArrowLeft, FileUp, Link2 } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -11,9 +11,14 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||||
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, type LoadingDetail } from '@/modules/loading-lists'
|
||||||
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
import {
|
||||||
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
|
confirmContainerOutboundDispatch,
|
||||||
|
getContainerOutboundDispatch,
|
||||||
|
type ContainerOutboundDispatchDetail,
|
||||||
|
type ContainerOutboundGateItem,
|
||||||
|
} from '@/modules/outbound-dispatch'
|
||||||
|
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||||
import {
|
import {
|
||||||
cancelContainerException,
|
cancelContainerException,
|
||||||
@@ -35,23 +40,29 @@ const access = useAccessPreviewStore()
|
|||||||
const containerId = computed(() => String(route.params.containerId || ''))
|
const containerId = computed(() => String(route.params.containerId || ''))
|
||||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||||
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
|
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
|
||||||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||||
|
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||||
|
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||||
|
const canViewContainerFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||||
|
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
|
||||||
|
&& detail.value?.sourceType === 'SIMULATION'
|
||||||
|
&& !detail.value.oceanBookingId
|
||||||
|
&& ['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detail = ref<ContainerBookingResource | null>(null)
|
const detail = ref<ContainerBookingResource | null>(null)
|
||||||
const overview = ref<ContainerOverview | null>(null)
|
const overview = ref<ContainerOverview | null>(null)
|
||||||
const error = ref<ApiErrorDetails | null>(null)
|
const error = ref<ApiErrorDetails | null>(null)
|
||||||
const overviewError = ref<ApiErrorDetails | null>(null)
|
const overviewError = ref<ApiErrorDetails | null>(null)
|
||||||
const documentOpen = ref(false)
|
const documentOpen = ref(false)
|
||||||
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
|
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||||
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
||||||
const documentFile = ref<File | null>(null)
|
const documentFile = ref<File | null>(null)
|
||||||
|
const documentUploadKey = ref(0)
|
||||||
const documentSaving = ref(false)
|
const documentSaving = ref(false)
|
||||||
const documentDownloading = ref(false)
|
const documentDownloading = ref(false)
|
||||||
const documentError = ref<ApiErrorDetails | null>(null)
|
const documentError = ref<ApiErrorDetails | null>(null)
|
||||||
const customsCompleting = ref(false)
|
|
||||||
const customsActionError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
||||||
const clearanceOpen = ref(false)
|
const clearanceOpen = ref(false)
|
||||||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||||||
@@ -69,9 +80,11 @@ const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
|||||||
const outboundError = ref<ApiErrorDetails | null>(null)
|
const outboundError = ref<ApiErrorDetails | null>(null)
|
||||||
const showPassedGates = ref(false)
|
const showPassedGates = ref(false)
|
||||||
const carrierRefreshKey = ref(0)
|
const carrierRefreshKey = ref(0)
|
||||||
|
const loadingDetail = ref<LoadingDetail | null>(null)
|
||||||
|
const loadingDetailError = ref<ApiErrorDetails | null>(null)
|
||||||
|
|
||||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||||
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
|
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
|
||||||
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
||||||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||||
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
|
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
|
||||||
@@ -99,6 +112,24 @@ const outboundGateSummary = computed(() => ({
|
|||||||
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
||||||
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||||
&& outboundGateSummary.value.blocked === 0)
|
&& outboundGateSummary.value.blocked === 0)
|
||||||
|
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
|
||||||
|
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||||||
|
const feeCustomers = computed(() => {
|
||||||
|
const customers = new Map<string, string | null>()
|
||||||
|
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||||
|
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||||
|
})
|
||||||
|
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
|
||||||
|
orderId: item.orderId,
|
||||||
|
orderNo: item.orderNo,
|
||||||
|
inboundId: item.inboundId,
|
||||||
|
inboundCode: item.inboundCode,
|
||||||
|
})))
|
||||||
|
|
||||||
|
function openOceanBookingLink() {
|
||||||
|
if (!detail.value || !canLinkOceanBooking.value) return
|
||||||
|
void router.push({ name: 'container-loading', query: { linkContainerId: detail.value.id } })
|
||||||
|
}
|
||||||
|
|
||||||
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
||||||
return {
|
return {
|
||||||
@@ -112,16 +143,14 @@ function replaceOverviewDocument(document: ContainerOverviewDocument) {
|
|||||||
if (!overview.value) return
|
if (!overview.value) return
|
||||||
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
|
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
|
||||||
}
|
}
|
||||||
async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') {
|
async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
|
||||||
let refreshCause: unknown
|
let refreshCause: unknown
|
||||||
let refreshed: ContainerOverview | null = null
|
let refreshed: ContainerOverview | null = null
|
||||||
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
|
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
|
||||||
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
|
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
|
||||||
if (!current) {
|
if (!current) {
|
||||||
try {
|
try {
|
||||||
const business = documentType === 'CUSTOMS_DECLARATION'
|
const business = await getContainerClearance(containerId.value)
|
||||||
? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null
|
|
||||||
: await getContainerClearance(containerId.value)
|
|
||||||
if (business) {
|
if (business) {
|
||||||
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
|
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
|
||||||
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
|
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
|
||||||
@@ -145,6 +174,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
||||||
detail.value = resource; overview.value = aggregate
|
detail.value = resource; overview.value = aggregate
|
||||||
|
await loadLoadingDetail(resource, aggregate)
|
||||||
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
@@ -158,6 +188,26 @@ async function loadOutboundDispatch() {
|
|||||||
finally { outboundLoading.value = false }
|
finally { outboundLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
|
||||||
|
loadingDetail.value = null; loadingDetailError.value = null
|
||||||
|
if (!canViewContainerFees.value || !aggregate.loadingPlanId || !resource.warehouseId) return
|
||||||
|
try {
|
||||||
|
if (resource.planStatus === 'COMPLETED') {
|
||||||
|
if (!canViewLoadingList.value) return
|
||||||
|
loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canViewPreload.value) return
|
||||||
|
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
|
||||||
|
} catch (cause) {
|
||||||
|
loadingDetailError.value = getApiErrorDetails(cause, '费用归属信息加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshAfterCustoms() {
|
||||||
|
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||||
|
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDeparture() {
|
async function confirmDeparture() {
|
||||||
const aggregate = outboundDetail.value?.aggregate
|
const aggregate = outboundDetail.value?.aggregate
|
||||||
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||||
@@ -191,13 +241,9 @@ function truckReferenceText(status?: string | null) {
|
|||||||
|
|
||||||
function openDocument(type: string) {
|
function openDocument(type: string) {
|
||||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
||||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
|
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !canEdit.value) return
|
||||||
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
|
|
||||||
documentType.value = type as UploadDocumentType
|
documentType.value = type as UploadDocumentType
|
||||||
documentFile.value = null; documentError.value = null; documentOpen.value = true
|
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
|
||||||
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
|
|
||||||
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
|
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
|
||||||
function removeDocumentFile() { documentFile.value = null }
|
function removeDocumentFile() { documentFile.value = null }
|
||||||
@@ -206,84 +252,41 @@ async function saveDocument() {
|
|||||||
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
||||||
try {
|
try {
|
||||||
const expectedVersion = selectedDocument.value?.version || 0
|
const expectedVersion = selectedDocument.value?.version || 0
|
||||||
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
const targetType = documentType.value
|
||||||
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
const targetTitle = documentTitle.value
|
||||||
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
|
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
|
||||||
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
|
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
|
||||||
const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value)
|
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
|
||||||
replaceOverviewDocument(optimistic)
|
replaceOverviewDocument(optimistic)
|
||||||
try {
|
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
|
||||||
const refreshed = await getContainerOverview(containerId.value)
|
ElMessage.success(`${targetTitle}已上传`)
|
||||||
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
|
try {
|
||||||
else {
|
const refreshed = await getContainerOverview(containerId.value)
|
||||||
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
|
const current = refreshed.documents.find((item) => item.documentType === targetType)
|
||||||
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
|
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
|
||||||
}
|
else {
|
||||||
|
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
|
||||||
|
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
|
||||||
}
|
}
|
||||||
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
|
||||||
} else {
|
|
||||||
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
|
|
||||||
overview.value = await getContainerOverview(containerId.value)
|
|
||||||
}
|
|
||||||
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
|
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
|
||||||
if (details.code === 'A0409') {
|
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
|
||||||
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
|
||||||
try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ }
|
|
||||||
} else {
|
|
||||||
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
documentError.value = details
|
documentError.value = details
|
||||||
}
|
}
|
||||||
finally { documentSaving.value = false }
|
finally { documentSaving.value = false }
|
||||||
}
|
}
|
||||||
async function refreshCustomsFacts() {
|
|
||||||
overview.value = await getContainerOverview(containerId.value)
|
|
||||||
await loadOutboundDispatch()
|
|
||||||
}
|
|
||||||
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
|
|
||||||
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
|
|
||||||
if (!overview.value?.loadingPlanId || !detail.value) {
|
|
||||||
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
|
|
||||||
} catch { customsCompleting.value = false; return }
|
|
||||||
try {
|
|
||||||
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
|
|
||||||
try { await refreshCustomsFacts() }
|
|
||||||
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
|
|
||||||
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
|
|
||||||
} catch (cause) {
|
|
||||||
const details = getApiErrorDetails(cause, '确认报关完成失败')
|
|
||||||
if (details.code === 'A0409') {
|
|
||||||
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
|
|
||||||
}
|
|
||||||
customsActionError.value = details
|
|
||||||
} finally { customsCompleting.value = false }
|
|
||||||
}
|
|
||||||
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
|
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
|
||||||
async function downloadDocument(item: ContainerOverviewDocument) {
|
async function downloadDocument(item: ContainerOverviewDocument) {
|
||||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||||||
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
|
|
||||||
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
documentDownloading.value = true
|
documentDownloading.value = true
|
||||||
try {
|
try {
|
||||||
const blob = item.documentType === 'CUSTOMS_DECLARATION'
|
const blob = item.documentType === 'DESTINATION_CLEARANCE'
|
||||||
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
|
|
||||||
: item.documentType === 'DESTINATION_CLEARANCE'
|
|
||||||
? await downloadContainerClearance(containerId.value, item.fileId)
|
? await downloadContainerClearance(containerId.value, item.fileId)
|
||||||
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
||||||
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const details = getApiErrorDetails(cause, '资料下载失败')
|
const details = getApiErrorDetails(cause, '资料下载失败')
|
||||||
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
|
|
||||||
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
|
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
|
||||||
ElMessage.error(details.message)
|
ElMessage.error(details.message)
|
||||||
}
|
}
|
||||||
@@ -303,7 +306,7 @@ async function saveException() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openClearance() {
|
async function openClearance() {
|
||||||
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true
|
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
|
||||||
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
|
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
|
||||||
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
|
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
|
||||||
finally { clearanceLoading.value = false }
|
finally { clearanceLoading.value = false }
|
||||||
@@ -312,17 +315,18 @@ function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw
|
|||||||
function removeClearanceFile() { clearanceFile.value = null }
|
function removeClearanceFile() { clearanceFile.value = null }
|
||||||
async function uploadClearance() {
|
async function uploadClearance() {
|
||||||
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
|
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
|
||||||
clearanceSaving.value = true; clearanceError.value = null
|
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
|
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
|
||||||
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
|
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
|
||||||
clearanceDocument.value = uploaded
|
clearanceDocument.value = uploaded
|
||||||
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE')))
|
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE')))
|
||||||
|
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
|
||||||
|
ElMessage.success('目的港清关资料已上传')
|
||||||
try {
|
try {
|
||||||
const refreshed = await getContainerOverview(containerId.value)
|
const refreshed = await getContainerOverview(containerId.value)
|
||||||
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] }
|
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] }
|
||||||
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
||||||
clearanceFile.value = null; ElMessage.success('目的港清关资料已上传')
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
|
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
|
||||||
if (details.code === 'A0409') {
|
if (details.code === 'A0409') {
|
||||||
@@ -334,8 +338,20 @@ async function uploadClearance() {
|
|||||||
}
|
}
|
||||||
async function completeClearance() {
|
async function completeClearance() {
|
||||||
if (!clearanceDocument.value) return
|
if (!clearanceDocument.value) return
|
||||||
clearanceSaving.value = true; clearanceError.value = null
|
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
|
||||||
try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
|
try {
|
||||||
|
const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成')
|
||||||
|
if (!completed) throw new Error('确认清关完成但未返回附件信息')
|
||||||
|
clearanceDocument.value = completed
|
||||||
|
const optimistic = toOverviewDocument(completed, 'DESTINATION_CLEARANCE')
|
||||||
|
replaceOverviewDocument(optimistic)
|
||||||
|
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
|
||||||
|
ElMessage.success('已完成清关')
|
||||||
|
try {
|
||||||
|
const refreshed = await getContainerOverview(containerId.value)
|
||||||
|
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, optimistic] }
|
||||||
|
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关已完成,但最新资料刷新失败;当前已保留完成事实') }
|
||||||
|
}
|
||||||
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
|
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
|
||||||
finally { clearanceSaving.value = false }
|
finally { clearanceSaving.value = false }
|
||||||
}
|
}
|
||||||
@@ -351,12 +367,31 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
|||||||
</section>
|
</section>
|
||||||
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><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>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
|
||||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
|
<section class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||||
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
<h2 id="container-information-heading">货柜信息</h2>
|
||||||
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
|
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
|
||||||
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
|
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||||
|
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||||
|
<div class="detail-fields"><dl><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 :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||||
|
<h2 id="container-customs-heading">报关清关信息</h2>
|
||||||
|
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['documents']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" />
|
||||||
|
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
||||||
|
<ContainerCustomsPanel v-if="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
|
||||||
|
<h2 id="container-receiving-heading">签收信息</h2>
|
||||||
|
<div class="detail-fields"><dl><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></div>
|
||||||
|
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
|
||||||
|
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
|
||||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||||
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||||
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
||||||
@@ -371,16 +406,24 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section surface-panel" aria-labelledby="container-fees-heading">
|
||||||
|
<h2 id="container-fees-heading">费用明细</h2>
|
||||||
|
<LoadErrorState v-if="loadingDetailError" compact title="费用明细加载失败" :message="loadingDetailError.message" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||||
|
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||||
|
<el-alert v-else-if="!canViewContainerFees" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||||
|
<el-empty v-else description="当前货柜尚无可读取的装柜费用上下文" :image-size="64" />
|
||||||
</section>
|
</section>
|
||||||
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||||
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||||
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 16px}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-section{display:grid;min-width:0;gap:12px;padding:12px 16px}.detail-section>h2{margin:0;font-size:15px}.detail-fields { padding:0; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:4px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
|||||||
import { listPlatformPartners } from '@/modules/master-data'
|
import { listPlatformPartners } from '@/modules/master-data'
|
||||||
import { listSuppliers } from '@/modules/suppliers'
|
import { listSuppliers } from '@/modules/suppliers'
|
||||||
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
|
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
|
||||||
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
|
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
|
||||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||||
@@ -20,7 +20,7 @@ vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOcean
|
|||||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||||
const accessCan = vi.hoisted(() => vi.fn())
|
const accessCan = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
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(), updateContainerTransport: vi.fn() }))
|
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() }))
|
||||||
|
|
||||||
function createTestRouter() {
|
function createTestRouter() {
|
||||||
return createRouter({ history: createMemoryHistory(), routes: [
|
return createRouter({ history: createMemoryHistory(), routes: [
|
||||||
@@ -219,7 +219,7 @@ describe('ContainersView', () => {
|
|||||||
|
|
||||||
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
|
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||||
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
|
const labels = ['真实集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
|
||||||
let previous = -1
|
let previous = -1
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
const current = source.indexOf(`label="${label}"`, previous + 1)
|
const current = source.indexOf(`label="${label}"`, previous + 1)
|
||||||
@@ -231,20 +231,61 @@ describe('ContainersView', () => {
|
|||||||
it('renders container business actions as buttons without changing the transport gate', () => {
|
it('renders container business actions as buttons without changing the transport gate', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||||
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
|
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
|
||||||
expect(source).toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
|
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
|
||||||
|
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
|
||||||
|
expect(source).toContain('初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。')
|
||||||
|
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
|
||||||
|
expect(source).not.toContain('containerNo: optional(form.containerNo)')
|
||||||
|
expect(source).not.toContain('updateContainerTransport')
|
||||||
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
|
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
|
||||||
expect(source).not.toContain('canSmartLoading && row.containerNo')
|
expect(source).not.toContain('canSmartLoading && row.containerNo')
|
||||||
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
||||||
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
|
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the existing released-booking workflow when entered from container detail', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||||
|
expect(source).toContain('route.query.linkContainerId')
|
||||||
|
expect(source).toContain('const target = await getContainer(linkContainerId)')
|
||||||
|
expect(source).toContain('await openLink(target)')
|
||||||
|
expect(source).toContain("void router.replace({ name: 'container-loading' })")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes the mandatory booking-link container number and keeps it as the only initial-entry write', async () => {
|
||||||
|
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/'); await router.isReady()
|
||||||
|
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
const vm = wrapper.vm as unknown as {
|
||||||
|
linkTarget: { id: string; version: number }
|
||||||
|
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
|
||||||
|
linkCandidateKey: string
|
||||||
|
linkContainerNo: string
|
||||||
|
saveLink: () => Promise<void>
|
||||||
|
}
|
||||||
|
vm.linkTarget = { id: '9001', version: 7 }
|
||||||
|
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
|
||||||
|
vm.linkCandidateKey = '101:202'
|
||||||
|
vm.linkContainerNo = ' mscu1234567 '
|
||||||
|
|
||||||
|
await vm.saveLink()
|
||||||
|
|
||||||
|
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
|
||||||
|
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7,
|
||||||
|
})
|
||||||
|
expect(vm.linkContainerNo).toBe('MSCU1234567')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
|
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||||
expect(source).not.toContain('uploadLoadingDeclaration')
|
expect(source).not.toContain('uploadLoadingDeclaration')
|
||||||
expect(source).not.toContain('uploadContainerClearance')
|
expect(source).not.toContain('uploadContainerClearance')
|
||||||
expect(source).not.toContain('uploadContainerDocument')
|
expect(source).not.toContain('uploadContainerDocument')
|
||||||
expect(detailSource).toContain('uploadLoadingDeclaration')
|
expect(detailSource).not.toContain('uploadLoadingDeclaration')
|
||||||
|
expect(detailSource).toContain('ContainerCustomsPanel')
|
||||||
expect(detailSource).toContain('uploadContainerClearance')
|
expect(detailSource).toContain('uploadContainerClearance')
|
||||||
expect(detailSource).toContain('uploadContainerDocument')
|
expect(detailSource).toContain('uploadContainerDocument')
|
||||||
})
|
})
|
||||||
@@ -361,8 +402,8 @@ describe('ContainersView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
|
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', 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', 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', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||||
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||||
const router = createTestRouter()
|
const router = createTestRouter()
|
||||||
await router.push('/'); await router.isReady()
|
await router.push('/'); await router.isReady()
|
||||||
@@ -381,8 +422,8 @@ describe('ContainersView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('clears the trucking supplier id and name together', async () => {
|
it('clears the trucking supplier id and name together', async () => {
|
||||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', 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', 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', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||||
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||||
const router = createTestRouter()
|
const router = createTestRouter()
|
||||||
await router.push('/'); await router.isReady()
|
await router.push('/'); await router.isReady()
|
||||||
@@ -404,7 +445,7 @@ describe('ContainersView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
|
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
|
||||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
|
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
|
||||||
vi.mocked(updateContainer).mockClear()
|
vi.mocked(updateContainer).mockClear()
|
||||||
const router = createTestRouter()
|
const router = createTestRouter()
|
||||||
await router.push('/'); await router.isReady()
|
await router.push('/'); await router.isReady()
|
||||||
@@ -426,7 +467,7 @@ describe('ContainersView', () => {
|
|||||||
describe('ContainersView trucking supplier contract', () => {
|
describe('ContainersView trucking supplier contract', () => {
|
||||||
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
|
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||||
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'customsDeclarationDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
|
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
|
||||||
let previous = source.indexOf("key: 'actions'")
|
let previous = source.indexOf("key: 'actions'")
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const current = source.indexOf(`key: '${field}'`, previous + 1)
|
const current = source.indexOf(`key: '${field}'`, previous + 1)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
|
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
@@ -18,13 +18,14 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
|
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
|
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
|
||||||
|
|
||||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||||
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
|
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const rows = ref<ContainerBookingResource[]>([])
|
const rows = ref<ContainerBookingResource[]>([])
|
||||||
const customers = ref<PlatformTenantResource[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
@@ -97,7 +98,6 @@ const canCustomsManage = computed(() => access.can('platform:firstleg:customs:ma
|
|||||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||||
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
|
||||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
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'))
|
||||||
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
|
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
|
||||||
@@ -146,7 +146,6 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
|
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
|
||||||
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
|
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
|
||||||
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
|
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
|
||||||
{ key: 'customsDeclarationDocumentStatus', label: '国内出口报关附件', minWidth: 150, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
|
|
||||||
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
|
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
|
||||||
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
|
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
|
||||||
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||||
@@ -166,6 +165,7 @@ const columns: DataTableColumn[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function optional(value: string | null) { return value?.trim() || null }
|
function optional(value: string | null) { return value?.trim() || null }
|
||||||
|
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
|
||||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
|
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 formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||||
@@ -203,7 +203,7 @@ function payload(): ContainerSaveRequest {
|
|||||||
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
|
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
|
||||||
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
|
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
|
||||||
return {
|
return {
|
||||||
containerNo: optional(form.containerNo),
|
containerNo: optional(detail.value?.containerNo || null),
|
||||||
warehouseId: form.warehouseId,
|
warehouseId: form.warehouseId,
|
||||||
warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName),
|
warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName),
|
||||||
billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo),
|
billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo),
|
||||||
@@ -394,7 +394,8 @@ async function openLink(source: object) {
|
|||||||
async function saveLink() {
|
async function saveLink() {
|
||||||
const target = linkTarget.value
|
const target = linkTarget.value
|
||||||
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
|
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
|
||||||
const containerNo = linkContainerNo.value.trim()
|
const containerNo = normalizedContainerNo(linkContainerNo.value)
|
||||||
|
linkContainerNo.value = containerNo
|
||||||
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
|
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
|
||||||
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
|
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
|
||||||
linkSaving.value = true
|
linkSaving.value = true
|
||||||
@@ -439,7 +440,16 @@ function handleMoreAction(command: string, source: object) {
|
|||||||
async function openEdit(source: object) {
|
async function openEdit(source: object) {
|
||||||
const row = source as ContainerBookingResource
|
const row = source as ContainerBookingResource
|
||||||
editorError.value = null
|
editorError.value = null
|
||||||
try { detail.value = await getContainer(row.id); replaceForm(detail.value); editorOpen.value = true }
|
try {
|
||||||
|
const latest = await getContainer(row.id)
|
||||||
|
if (!['WAIT_LOADING', 'EDITING'].includes(latest.planStatus)) {
|
||||||
|
ElMessage.warning('当前阶段运输信息只读;真实集装箱号如需纠错,请进入预装清单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail.value = latest
|
||||||
|
replaceForm(latest)
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
|
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -451,8 +461,7 @@ async function save() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
editorError.value = null
|
editorError.value = null
|
||||||
try {
|
try {
|
||||||
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
|
await updateContainer(form.id, form.version, payload())
|
||||||
else await updateContainer(form.id, form.version, payload())
|
|
||||||
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
|
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const details = getApiErrorDetails(error, '保存失败')
|
const details = getApiErrorDetails(error, '保存失败')
|
||||||
@@ -495,7 +504,23 @@ watch(linkOpen, (open) => {
|
|||||||
linkSequence += 1
|
linkSequence += 1
|
||||||
linkLoading.value = false
|
linkLoading.value = false
|
||||||
})
|
})
|
||||||
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()])
|
||||||
|
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
|
||||||
|
if (!linkContainerId || !canEdit.value) return
|
||||||
|
try {
|
||||||
|
const target = await getContainer(linkContainerId)
|
||||||
|
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !['WAIT_LOADING', 'EDITING'].includes(target.planStatus)) {
|
||||||
|
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await openLink(target)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
|
||||||
|
} finally {
|
||||||
|
void router.replace({ name: 'container-loading' })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -511,7 +536,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
|||||||
<section v-if="!loadError" class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
|
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
|
||||||
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
|
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
|
||||||
@@ -541,34 +566,33 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
|||||||
|
|
||||||
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
|
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
|
||||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
|
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
|
||||||
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
|
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
|
||||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||||
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||||
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||||
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
|
||||||
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
|
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
||||||
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待录入'" disabled /><small>初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。</small></el-form-item>
|
||||||
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
|
|
||||||
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
|
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
|
||||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||||
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
|
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
|
||||||
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
|
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
||||||
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||||
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" :disabled="transportOnly" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||||
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" :disabled="transportOnly" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
|
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
|
||||||
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||||
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||||
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" :disabled="transportOnly" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
|
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
|
||||||
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
|
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
|
||||||
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
|
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
|
||||||
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" :disabled="transportOnly" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
|
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
|
||||||
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
|
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
|
||||||
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
|
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
|
||||||
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" :disabled="transportOnly" /></el-form-item>
|
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { resolve } from 'node:path'
|
|||||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||||
import {
|
import {
|
||||||
|
assignSmartLoadingGroup,
|
||||||
|
assignSmartLoadingItem,
|
||||||
getSmartLoadingPlanByBooking,
|
getSmartLoadingPlanByBooking,
|
||||||
listContainers,
|
listContainers,
|
||||||
listSmartLoadingCandidates,
|
listSmartLoadingCandidates,
|
||||||
recommendSmartLoading,
|
recommendSmartLoading,
|
||||||
|
removeSmartLoadingGroup,
|
||||||
removeSmartLoadingItem,
|
removeSmartLoadingItem,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
|
|
||||||
@@ -33,11 +36,14 @@ vi.mock('../api/platformContainerLoading', () => ({
|
|||||||
listSmartLoadingCandidates: vi.fn(),
|
listSmartLoadingCandidates: vi.fn(),
|
||||||
createSmartLoadingPlan: vi.fn(),
|
createSmartLoadingPlan: vi.fn(),
|
||||||
assignSmartLoadingItem: vi.fn(),
|
assignSmartLoadingItem: vi.fn(),
|
||||||
|
assignSmartLoadingGroup: vi.fn(),
|
||||||
recommendSmartLoading: vi.fn(),
|
recommendSmartLoading: vi.fn(),
|
||||||
adjustSmartLoadingItem: vi.fn(),
|
adjustSmartLoadingItem: vi.fn(),
|
||||||
removeSmartLoadingItem: vi.fn(),
|
removeSmartLoadingItem: vi.fn(),
|
||||||
|
removeSmartLoadingGroup: vi.fn(),
|
||||||
pushSmartLoadingPreload: vi.fn(),
|
pushSmartLoadingPreload: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
|
||||||
|
|
||||||
const container = {
|
const container = {
|
||||||
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||||
@@ -61,7 +67,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
|
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
|
||||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }])
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
|
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
|
||||||
@@ -94,7 +100,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
total: 1,
|
total: 1,
|
||||||
pages: 1,
|
pages: 1,
|
||||||
})
|
})
|
||||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }])
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
|
||||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -184,13 +190,60 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
||||||
expect(source).toContain("label: customer.tenantName")
|
expect(source).toContain("label: customer.tenantName")
|
||||||
expect(source).toContain('item.accountId !== customerId')
|
expect(source).toContain('member.accountId === customerId')
|
||||||
expect(source).not.toContain('customer.tenantCode')
|
expect(source).not.toContain('customer.tenantCode')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults candidate assignment to all available boxes', () => {
|
it('defaults ordinary candidate assignment to all available boxes', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount')
|
expect(source).toContain(': candidate.availablePackageCount')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregates a two-order candidate group and calls only whole-group assignment', async () => {
|
||||||
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||||
|
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||||
|
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '10', destinationWarehouseCode: 'JFK8', destinationWarehouseName: 'JFK8', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 2, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.4, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||||
|
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户二', goodsSummary: '货物二', availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||||
|
])
|
||||||
|
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(plan)
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
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('整组分配'))
|
||||||
|
expect(buttons).toHaveLength(1)
|
||||||
|
await buttons[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||||
|
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||||
|
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(plan.id, '201', plan.version)
|
||||||
|
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks an incomplete three-member candidate group without exposing member assignment', async () => {
|
||||||
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({
|
||||||
|
accountId: `${index + 1}`, inboundId, orderId: `50${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: `客户${index}`, goodsSummary: `货物${index}`, availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '9900', groupNo: 'CLG-9900', memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票',
|
||||||
|
})))
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||||||
|
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||||||
|
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))!
|
||||||
|
expect(button.attributes('disabled')).toBeDefined()
|
||||||
|
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
|
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||||
|
expect(source).toContain('.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
||||||
@@ -296,8 +349,8 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
|
|
||||||
it('matches candidate goods by their own formal top-level channel code', async () => {
|
it('matches candidate goods by their own formal top-level channel code', async () => {
|
||||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||||
{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsSummary: '匹配货物', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 },
|
{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsSummary: '匹配货物', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null },
|
||||||
{ accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6 },
|
{ accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null },
|
||||||
])
|
])
|
||||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -339,7 +392,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
'ERR_BAD_REQUEST',
|
'ERR_BAD_REQUEST',
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
{ data: { code: 'A0409', message: '已有拣货记录的明细不能删除' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
|
{ data: { code: 'A0409', message: '已有拣货记录的明细不能删除', requestId: 'request-remove-1', traceId: 'trace-remove-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
|
||||||
))
|
))
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
|
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
|
||||||
const errorMessage = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
|
const errorMessage = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
|
||||||
@@ -353,8 +406,9 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
|
|
||||||
expect(removeSmartLoadingItem).toHaveBeenCalled()
|
expect(removeSmartLoadingItem).toHaveBeenCalled()
|
||||||
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
|
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
|
||||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
expect(wrapper.text()).toContain('装柜操作失败')
|
||||||
expect(wrapper.text()).not.toContain('该订单在拣货中不许删除')
|
expect(wrapper.text()).toContain('request-remove-1')
|
||||||
|
expect(wrapper.text()).toContain('trace-remove-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes every item with the latest returned plan version', async () => {
|
it('removes every item with the latest returned plan version', async () => {
|
||||||
@@ -444,7 +498,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
||||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8 }])
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
|
||||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -496,6 +550,10 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(source).toContain('智能推荐')
|
expect(source).toContain('智能推荐')
|
||||||
expect(source).toContain('推送预装')
|
expect(source).toContain('推送预装')
|
||||||
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
|
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
|
||||||
|
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
|
||||||
|
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
|
||||||
|
expect(source).not.toContain('关联并放舱真实订舱')
|
||||||
|
expect(source).not.toContain('订舱放舱')
|
||||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
|
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
|||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
adjustSmartLoadingItem,
|
adjustSmartLoadingItem,
|
||||||
|
assignSmartLoadingGroup,
|
||||||
assignSmartLoadingItem,
|
assignSmartLoadingItem,
|
||||||
createSmartLoadingPlan,
|
createSmartLoadingPlan,
|
||||||
getSmartLoadingPlanByBooking,
|
getSmartLoadingPlanByBooking,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
listSmartLoadingCandidates,
|
listSmartLoadingCandidates,
|
||||||
pushSmartLoadingPreload,
|
pushSmartLoadingPreload,
|
||||||
recommendSmartLoading,
|
recommendSmartLoading,
|
||||||
|
removeSmartLoadingGroup,
|
||||||
removeSmartLoadingItem,
|
removeSmartLoadingItem,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
import type {
|
import type {
|
||||||
@@ -29,6 +31,7 @@ import type {
|
|||||||
SmartLoadingPlan,
|
SmartLoadingPlan,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||||
|
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||||
|
|
||||||
@@ -46,11 +49,13 @@ const selectionLoading = ref(false)
|
|||||||
const mutating = ref(false)
|
const mutating = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const selectionError = ref<ApiErrorDetails | null>(null)
|
const selectionError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||||
const noDraft = ref(false)
|
const noDraft = ref(false)
|
||||||
const assignOpen = ref(false)
|
const assignOpen = ref(false)
|
||||||
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||||
const assignForm = reactive({ packageCount: 1 })
|
const assignForm = reactive({ packageCount: 1 })
|
||||||
const quantityDrafts = reactive<Record<string, number>>({})
|
const quantityDrafts = reactive<Record<string, number>>({})
|
||||||
|
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
|
||||||
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
|
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
|
||||||
const appliedQuery = ref<QueryModel>({ ...query.value })
|
const appliedQuery = ref<QueryModel>({ ...query.value })
|
||||||
let selectionRequest = 0
|
let selectionRequest = 0
|
||||||
@@ -79,7 +84,88 @@ const editable = computed(() => plan.value?.canEdit === true)
|
|||||||
const assignable = computed(() => plan.value?.canAssign === true)
|
const assignable = computed(() => plan.value?.canAssign === true)
|
||||||
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
||||||
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
||||||
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
|
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
|
||||||
|
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
|
||||||
|
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
|
||||||
|
for (const member of members) {
|
||||||
|
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
|
||||||
|
const inboundCode = member.inboundCode || '入仓码暂缺'
|
||||||
|
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
|
||||||
|
orders.set(member.orderId, current)
|
||||||
|
}
|
||||||
|
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
|
||||||
|
}
|
||||||
|
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
|
||||||
|
return group.inboundFragmentCount === group.memberOrderCount
|
||||||
|
? `${group.memberOrderCount} 票`
|
||||||
|
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
|
||||||
|
}
|
||||||
|
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||||
|
const rows: SmartLoadingCandidateRow[] = []
|
||||||
|
const groups = new Map<string, SmartLoadingCandidate[]>()
|
||||||
|
for (const candidate of candidates.value) {
|
||||||
|
if (!candidate.coLoadGroupId) {
|
||||||
|
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
|
||||||
|
}
|
||||||
|
for (const members of groups.values()) {
|
||||||
|
const first = members[0]
|
||||||
|
rows.push({
|
||||||
|
...first,
|
||||||
|
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
|
||||||
|
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
|
||||||
|
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
|
||||||
|
availablePackageCount: Number(first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0)),
|
||||||
|
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
|
||||||
|
sourceCandidates: members,
|
||||||
|
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
|
||||||
|
memberOrderSummaries: memberOrderSummaries(members),
|
||||||
|
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
|
||||||
|
inboundFragmentCount: Number(first.memberCount || members.length),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||||
|
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||||
|
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||||
|
const result: SmartLoadingPlanRow[] = []
|
||||||
|
const grouped = new Map<string, SmartLoadingInboundItem[]>()
|
||||||
|
for (const row of inboundRows) {
|
||||||
|
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||||
|
if (!group) {
|
||||||
|
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
|
||||||
|
}
|
||||||
|
for (const rows of grouped.values()) {
|
||||||
|
const first = rows[0]
|
||||||
|
const group = planGroupsByInboundId.value.get(first.inboundId)!
|
||||||
|
result.push({
|
||||||
|
...first,
|
||||||
|
id: `coload:${group.groupId}`,
|
||||||
|
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
|
||||||
|
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
|
||||||
|
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
|
||||||
|
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
|
||||||
|
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
|
||||||
|
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
|
||||||
|
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
|
||||||
|
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
|
||||||
|
canAdjust: false,
|
||||||
|
canRemove: rows.every((row) => row.canRemove),
|
||||||
|
sourceItems: rows.flatMap((row) => row.sourceItems),
|
||||||
|
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
|
||||||
|
coLoadGroup: group,
|
||||||
|
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
|
||||||
|
memberOrderSummaries: memberOrderSummaries(rows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||||
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
||||||
@@ -103,10 +189,10 @@ const filteredCandidates = computed(() => {
|
|||||||
const customerId = String(appliedQuery.value.customerId || '')
|
const customerId = String(appliedQuery.value.customerId || '')
|
||||||
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
|
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
|
||||||
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
|
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
|
||||||
return candidates.value.filter((item) => {
|
return displayedCandidates.value.filter((item) => {
|
||||||
if (!channelCode || item.channelCode !== channelCode) return false
|
if (!channelCode || item.channelCode !== channelCode) return false
|
||||||
if (customerId && item.accountId !== customerId) return false
|
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
|
||||||
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
|
return !inboundCode || item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -120,6 +206,7 @@ function preferredContainerId() {
|
|||||||
const candidateColumns: DataTableColumn[] = [
|
const candidateColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
||||||
|
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
|
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
|
||||||
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
|
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
|
||||||
@@ -129,6 +216,7 @@ const candidateColumns: DataTableColumn[] = [
|
|||||||
const itemColumns: DataTableColumn[] = [
|
const itemColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
||||||
|
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||||
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
||||||
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
|
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
|
||||||
@@ -139,7 +227,7 @@ function formatNumber(value: number, digits = 3) {
|
|||||||
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
|
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
|
||||||
}
|
}
|
||||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
|
||||||
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
||||||
function adjustmentMax(source: object) {
|
function adjustmentMax(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
@@ -178,6 +266,8 @@ function clearSelectionData() {
|
|||||||
candidates.value = []
|
candidates.value = []
|
||||||
noDraft.value = false
|
noDraft.value = false
|
||||||
selectionError.value = null
|
selectionError.value = null
|
||||||
|
mutationError.value = null
|
||||||
|
planGroupsByInboundId.value = new Map()
|
||||||
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +289,11 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
|
|||||||
])
|
])
|
||||||
if (request !== selectionRequest) return
|
if (request !== selectionRequest) return
|
||||||
candidates.value = candidateResult
|
candidates.value = candidateResult
|
||||||
if (planResult.value) acceptPlan(planResult.value, preserved, false)
|
if (planResult.value) {
|
||||||
|
acceptPlan(planResult.value, preserved, false)
|
||||||
|
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
|
||||||
|
syncDrafts(planResult.value, preserved)
|
||||||
|
}
|
||||||
else noDraft.value = planResult.missing
|
else noDraft.value = planResult.missing
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
|
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
|
||||||
@@ -274,27 +368,39 @@ async function createPlan() {
|
|||||||
}
|
}
|
||||||
function openAssign(source: object) {
|
function openAssign(source: object) {
|
||||||
const candidate = candidateRow(source)
|
const candidate = candidateRow(source)
|
||||||
|
if (candidateBlocked(candidate)) return
|
||||||
selectedCandidate.value = candidate
|
selectedCandidate.value = candidate
|
||||||
assignForm.packageCount = candidate.availablePackageCount
|
assignForm.packageCount = candidate.coLoadGroupId
|
||||||
|
? Number(candidate.groupTotalBoxes || candidate.availablePackageCount)
|
||||||
|
: candidate.availablePackageCount
|
||||||
assignOpen.value = true
|
assignOpen.value = true
|
||||||
}
|
}
|
||||||
async function assign() {
|
async function assign() {
|
||||||
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
||||||
if (assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
|
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
|
||||||
|
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
|
||||||
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
|
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
|
const selected = selectedCandidate.value!
|
||||||
inboundId: selectedCandidate.value!.inboundId,
|
acceptPlan(selected.coLoadGroupId
|
||||||
packageCount: assignForm.packageCount,
|
? await assignSmartLoadingGroup(plan.value!.id, selected.inboundId, plan.value!.version)
|
||||||
expectedVersion: plan.value!.version,
|
: await assignSmartLoadingItem(plan.value!.id, {
|
||||||
}))
|
inboundId: selected.inboundId,
|
||||||
|
packageCount: assignForm.packageCount,
|
||||||
|
expectedVersion: plan.value!.version,
|
||||||
|
}))
|
||||||
assignOpen.value = false
|
assignOpen.value = false
|
||||||
await loadSelectedData()
|
await loadSelectedData()
|
||||||
ElMessage.success('明细已加入当前柜次')
|
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
|
||||||
}, '分配明细失败', { preserveAssign: true })
|
}, '分配明细失败', { preserveAssign: true })
|
||||||
}
|
}
|
||||||
|
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
|
||||||
|
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||||
|
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|
||||||
|
|| candidate.availablePackageCount < 1
|
||||||
|
}
|
||||||
async function recommend() {
|
async function recommend() {
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
@@ -330,11 +436,20 @@ async function adjust(source: object) {
|
|||||||
async function remove(source: object) {
|
async function remove(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value || !item.canRemove) return
|
if (!plan.value || !item.canRemove) return
|
||||||
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', {
|
const row = source as SmartLoadingPlanRow
|
||||||
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
const confirmed = await ElMessageBox.confirm(row.coLoadGroup
|
||||||
|
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||||
|
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', {
|
||||||
|
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消',
|
||||||
}).catch(() => false)
|
}).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
|
if (row.coLoadGroup) {
|
||||||
|
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
|
||||||
|
await loadSelectedData()
|
||||||
|
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
|
||||||
|
return
|
||||||
|
}
|
||||||
let current = plan.value!
|
let current = plan.value!
|
||||||
for (const original of item.sourceItems) {
|
for (const original of item.sourceItems) {
|
||||||
const currentItem = current.items.find((value) => value.id === original.id)
|
const currentItem = current.items.find((value) => value.id === original.id)
|
||||||
@@ -357,6 +472,11 @@ async function removeAll() {
|
|||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
let current = plan.value!
|
let current = plan.value!
|
||||||
for (const inboundItem of removableItems) {
|
for (const inboundItem of removableItems) {
|
||||||
|
if (inboundItem.coLoadGroup) {
|
||||||
|
current = await removeSmartLoadingGroup(current.id, inboundItem.coLoadGroup.groupId, current.version)
|
||||||
|
acceptPlan(current)
|
||||||
|
continue
|
||||||
|
}
|
||||||
for (const original of inboundItem.sourceItems) {
|
for (const original of inboundItem.sourceItems) {
|
||||||
const item = current.items.find((value) => value.id === original.id)
|
const item = current.items.find((value) => value.id === original.id)
|
||||||
if (!item) continue
|
if (!item) continue
|
||||||
@@ -370,13 +490,13 @@ async function removeAll() {
|
|||||||
}
|
}
|
||||||
async function pushPreload() {
|
async function pushPreload() {
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
|
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', {
|
||||||
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
|
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
|
||||||
}).catch(() => false)
|
}).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
|
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
|
||||||
ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
|
ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认')
|
||||||
}, '推送预装清单失败')
|
}, '推送预装清单失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +506,7 @@ async function mutate(
|
|||||||
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
|
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
|
mutationError.value = null
|
||||||
try {
|
try {
|
||||||
await action()
|
await action()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -402,6 +523,7 @@ async function mutate(
|
|||||||
} else {
|
} else {
|
||||||
ElMessage.error(details.message)
|
ElMessage.error(details.message)
|
||||||
}
|
}
|
||||||
|
mutationError.value = details
|
||||||
} finally {
|
} finally {
|
||||||
mutating.value = false
|
mutating.value = false
|
||||||
}
|
}
|
||||||
@@ -440,6 +562,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
|
||||||
|
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
||||||
|
</LoadErrorState>
|
||||||
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
||||||
<section class="dispatch-pane candidate-pane">
|
<section class="dispatch-pane candidate-pane">
|
||||||
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
|
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
|
||||||
@@ -458,10 +583,11 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
:disabled="assignedInboundIds.has(row.inboundId) || row.availablePackageCount < 1"
|
:disabled="candidateBlocked(row as SmartLoadingCandidateRow)"
|
||||||
@click="openAssign(row)"
|
@click="openAssign(row)"
|
||||||
><Plus :size="15" />加入</el-button>
|
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
|
||||||
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
|
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
|
||||||
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
|
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
|
||||||
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
||||||
@@ -489,7 +615,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
|
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
|
||||||
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
|
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
|
||||||
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
|
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
|
||||||
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
|
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
|
||||||
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
|
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,8 +665,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust && !row.coLoadGroup" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />{{ row.coLoadGroup ? '移除整组' : '移除整票' }}</el-button><small v-if="row.coLoadGroup">整组固定全箱数,不允许单票调整</small><small v-else-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></template>
|
||||||
|
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
|
||||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
@@ -548,12 +675,19 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
|
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||||
<div v-if="selectedCandidate" class="assign-dialog">
|
<div v-if="selectedCandidate" class="assign-dialog">
|
||||||
<p><strong>{{ selectedCandidate.customerName || '客户待补充' }}</strong></p>
|
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
|
||||||
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
|
<template v-if="selectedCandidate.coLoadGroupId">
|
||||||
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
|
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
|
||||||
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
|
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
|
||||||
|
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
|
||||||
|
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
|
||||||
|
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -603,9 +737,12 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
.readonly-status-card strong { font-size: 16px; }
|
.readonly-status-card strong { font-size: 16px; }
|
||||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
||||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||||
.plan-pane :deep(.el-input-number) { width: 120px; }
|
.package-count-editor { display: flex; min-width: 0; }
|
||||||
|
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||||
.assign-dialog p { margin: 0 0 10px; }
|
.assign-dialog p { margin: 0 0 10px; }
|
||||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||||
|
.candidate-group { display: grid; gap: 4px; }
|
||||||
|
.candidate-block { color: var(--el-color-danger) !important; }
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
||||||
.embedded-smart-loading .dispatch-grid { height: auto; }
|
.embedded-smart-loading .dispatch-grid { height: auto; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
@@ -7,13 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
||||||
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading } from '../api/platformContainerLoading'
|
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
|
||||||
|
|
||||||
vi.mock('../api/platformContainerLoading', () => ({
|
vi.mock('../api/platformContainerLoading', () => ({
|
||||||
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
||||||
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(),
|
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
|
||||||
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(),
|
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
|
||||||
const container = {
|
const container = {
|
||||||
@@ -30,6 +31,7 @@ const planResponse = {
|
|||||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [],
|
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [],
|
||||||
}
|
}
|
||||||
|
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }
|
||||||
|
|
||||||
describe('SmartLoadingWorkbenchView', () => {
|
describe('SmartLoadingWorkbenchView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -37,7 +39,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(getContainer).mockResolvedValue(container)
|
vi.mocked(getContainer).mockResolvedValue(container)
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
|
||||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }])
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores the real draft and explains candidate capacity', async () => {
|
it('restores the real draft and explains candidate capacity', async () => {
|
||||||
@@ -99,6 +101,10 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
||||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
||||||
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
|
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
|
||||||
|
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
|
||||||
|
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
|
||||||
|
expect(source).not.toContain('关联并放舱真实订舱')
|
||||||
|
expect(source).not.toContain('订舱放舱')
|
||||||
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
||||||
expect(source).not.toContain("label: '目的仓'")
|
expect(source).not.toContain("label: '目的仓'")
|
||||||
expect(source).not.toContain('plan.planNo')
|
expect(source).not.toContain('plan.planNo')
|
||||||
@@ -107,4 +113,68 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
expect(source).toContain('<span>集装箱号</span>')
|
expect(source).toContain('<span>集装箱号</span>')
|
||||||
expect(source).not.toContain('当前柜号')
|
expect(source).not.toContain('当前柜号')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||||||
|
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||||
|
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregates a two-order group and calls only the atomic whole-group assignment', async () => {
|
||||||
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
|
||||||
|
{ ...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 4, availableVolumeCbm: 0.8, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||||
|
{ ...candidate, inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', availablePackageCount: 2, availableVolumeCbm: 0.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||||
|
{ ...candidate, accountId: '2', inboundId: '202', orderId: '302', inboundCode: 'IN-202', availablePackageCount: 6, availableVolumeCbm: 1.2, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
|
||||||
|
])
|
||||||
|
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse)
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||||
|
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||||
|
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('CLG-9007199254740999 · 2 票')
|
||||||
|
expect(wrapper.text()).toContain('3 个入仓片段')
|
||||||
|
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
|
||||||
|
expect(wrapper.text()).toContain('整组 12 箱')
|
||||||
|
expect(wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))).toHaveLength(1)
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('整组分配'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
|
||||||
|
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an exact three-member group block and prevents individual assignment', async () => {
|
||||||
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({
|
||||||
|
...candidate, inboundId, orderId: `5${index}`, inboundCode: `IN-${inboundId}`, coLoadGroupId: '9900', groupNo: 'CLG-9900',
|
||||||
|
memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票',
|
||||||
|
})))
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||||
|
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||||
|
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||||||
|
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||||||
|
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))!
|
||||||
|
expect(button.attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.text()).not.toContain('本次计划装柜箱数')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an ordinary ungrouped inbound on the existing partial-count assignment', async () => {
|
||||||
|
vi.mocked(assignSmartLoadingItem).mockResolvedValue(planResponse)
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||||
|
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||||
|
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text() === '分配')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('本次计划装柜箱数')
|
||||||
|
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
|
||||||
|
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
||||||
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
|
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
|
||||||
recommendSmartLoading, removeSmartLoadingItem,
|
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
|
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
|
||||||
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||||
|
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -31,9 +32,10 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
|||||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||||
const noDraft = ref(false)
|
const noDraft = ref(false)
|
||||||
const assignOpen = ref(false)
|
const assignOpen = ref(false)
|
||||||
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||||
const assignForm = reactive({ packageCount: 1 })
|
const assignForm = reactive({ packageCount: 1 })
|
||||||
const quantityDrafts = reactive<Record<string, number>>({})
|
const quantityDrafts = reactive<Record<string, number>>({})
|
||||||
|
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
|
||||||
|
|
||||||
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
|
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
|
||||||
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
|
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
|
||||||
@@ -43,7 +45,90 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
|
|||||||
const editable = computed(() => plan.value?.canEdit === true)
|
const editable = computed(() => plan.value?.canEdit === true)
|
||||||
const assignable = computed(() => plan.value?.canAssign === true)
|
const assignable = computed(() => plan.value?.canAssign === true)
|
||||||
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
||||||
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
|
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
|
||||||
|
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
|
||||||
|
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
|
||||||
|
for (const member of members) {
|
||||||
|
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
|
||||||
|
const inboundCode = member.inboundCode || '入仓码暂缺'
|
||||||
|
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
|
||||||
|
orders.set(member.orderId, current)
|
||||||
|
}
|
||||||
|
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
|
||||||
|
}
|
||||||
|
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
|
||||||
|
return group.inboundFragmentCount === group.memberOrderCount
|
||||||
|
? `${group.memberOrderCount} 票`
|
||||||
|
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
|
||||||
|
}
|
||||||
|
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||||
|
const rows: SmartLoadingCandidateRow[] = []
|
||||||
|
const groups = new Map<string, SmartLoadingCandidate[]>()
|
||||||
|
for (const candidate of candidates.value) {
|
||||||
|
if (!candidate.coLoadGroupId) {
|
||||||
|
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
|
||||||
|
}
|
||||||
|
for (const members of groups.values()) {
|
||||||
|
const first = members[0]
|
||||||
|
rows.push({
|
||||||
|
...first,
|
||||||
|
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
|
||||||
|
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
|
||||||
|
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
|
||||||
|
availablePackageCount: first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0),
|
||||||
|
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
|
||||||
|
sourceCandidates: members,
|
||||||
|
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
|
||||||
|
memberOrderSummaries: memberOrderSummaries(members),
|
||||||
|
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
|
||||||
|
inboundFragmentCount: Number(first.memberCount || members.length),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||||
|
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||||
|
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||||
|
const result: SmartLoadingPlanRow[] = []
|
||||||
|
const grouped = new Map<string, SmartLoadingInboundItem[]>()
|
||||||
|
for (const row of inboundRows) {
|
||||||
|
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||||
|
if (!group) {
|
||||||
|
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
|
||||||
|
}
|
||||||
|
for (const rows of grouped.values()) {
|
||||||
|
const first = rows[0]
|
||||||
|
const group = planGroupsByInboundId.value.get(first.inboundId)!
|
||||||
|
result.push({
|
||||||
|
...first,
|
||||||
|
id: `coload:${group.groupId}`,
|
||||||
|
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
|
||||||
|
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
|
||||||
|
skuCode: [...new Set(rows.map((row) => row.skuCode).filter(Boolean))].join('、'),
|
||||||
|
productNameZh: [...new Set(rows.map((row) => row.productNameZh).filter(Boolean))].join('、'),
|
||||||
|
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
|
||||||
|
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
|
||||||
|
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
|
||||||
|
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
|
||||||
|
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
|
||||||
|
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
|
||||||
|
canAdjust: false,
|
||||||
|
canRemove: rows.every((row) => row.canRemove),
|
||||||
|
sourceItems: rows.flatMap((row) => row.sourceItems),
|
||||||
|
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
|
||||||
|
coLoadGroup: group,
|
||||||
|
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
|
||||||
|
memberOrderSummaries: memberOrderSummaries(rows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||||
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
||||||
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
|
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
|
||||||
@@ -57,6 +142,7 @@ const itemColumns: DataTableColumn[] = [
|
|||||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
||||||
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
||||||
|
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||||
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
|
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
|
||||||
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
|
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
|
||||||
@@ -66,6 +152,7 @@ const itemColumns: DataTableColumn[] = [
|
|||||||
const candidateColumns: DataTableColumn[] = [
|
const candidateColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
||||||
|
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||||
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true },
|
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true },
|
||||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
||||||
@@ -76,7 +163,7 @@ const candidateColumns: DataTableColumn[] = [
|
|||||||
|
|
||||||
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
|
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
|
||||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
|
||||||
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
||||||
function explainCandidate(source: object) {
|
function explainCandidate(source: object) {
|
||||||
const candidate = candidateRow(source)
|
const candidate = candidateRow(source)
|
||||||
@@ -100,9 +187,15 @@ function syncDrafts(preserved: Record<string, number> = {}) {
|
|||||||
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
||||||
}
|
}
|
||||||
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
|
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
|
||||||
|
async function acceptPlanWithGroups(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
||||||
|
acceptPlan(next, preserved)
|
||||||
|
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(next)
|
||||||
|
syncDrafts(preserved)
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true; loadError.value = null; noDraft.value = false
|
loading.value = true; loadError.value = null; noDraft.value = false
|
||||||
|
planGroupsByInboundId.value = new Map()
|
||||||
try {
|
try {
|
||||||
container.value = await getContainer(containerId.value)
|
container.value = await getContainer(containerId.value)
|
||||||
if (container.value.planStatus === 'COMPLETED') {
|
if (container.value.planStatus === 'COMPLETED') {
|
||||||
@@ -110,7 +203,7 @@ async function load() {
|
|||||||
candidates.value = []
|
candidates.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
|
try { await acceptPlanWithGroups(await getSmartLoadingPlanByBooking(containerId.value)) }
|
||||||
catch (error) {
|
catch (error) {
|
||||||
const detail = getApiErrorDetails(error)
|
const detail = getApiErrorDetails(error)
|
||||||
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
|
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
|
||||||
@@ -132,34 +225,51 @@ function openOperationalList() {
|
|||||||
async function createPlan() {
|
async function createPlan() {
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
|
try { await acceptPlanWithGroups(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
|
||||||
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
|
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
|
||||||
finally { mutating.value = false }
|
finally { mutating.value = false }
|
||||||
}
|
}
|
||||||
function openAssign(source: object) { const candidate = candidateRow(source); selectedCandidate.value = candidate; assignForm.packageCount = Math.min(candidate.availablePackageCount, 1); assignOpen.value = true }
|
function openAssign(source: object) {
|
||||||
|
const candidate = candidateRow(source)
|
||||||
|
if (candidateBlocked(candidate)) return
|
||||||
|
selectedCandidate.value = candidate
|
||||||
|
assignForm.packageCount = candidate.coLoadGroupId ? Number(candidate.groupTotalBoxes || candidate.availablePackageCount) : Math.min(candidate.availablePackageCount, 1)
|
||||||
|
assignOpen.value = true
|
||||||
|
}
|
||||||
async function assign() {
|
async function assign() {
|
||||||
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
||||||
|
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try {
|
try {
|
||||||
acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }))
|
const selected = selectedCandidate.value
|
||||||
assignOpen.value = false; ElMessage.success('货物已分配')
|
const next = selected.coLoadGroupId
|
||||||
|
? await assignSmartLoadingGroup(plan.value.id, selected.inboundId, plan.value.version)
|
||||||
|
: await assignSmartLoadingItem(plan.value.id, { inboundId: selected.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version })
|
||||||
|
await acceptPlanWithGroups(next)
|
||||||
|
assignOpen.value = false
|
||||||
|
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配')
|
||||||
} catch (error) { await handleMutationError(error, '分配失败') }
|
} catch (error) { await handleMutationError(error, '分配失败') }
|
||||||
finally { mutating.value = false }
|
finally { mutating.value = false }
|
||||||
}
|
}
|
||||||
|
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
|
||||||
|
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||||
|
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|
||||||
|
|| candidate.availablePackageCount < 1
|
||||||
|
}
|
||||||
async function recommend() {
|
async function recommend() {
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
|
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const details = getApiErrorDetails(error, '智能推荐失败')
|
const details = getApiErrorDetails(error, '智能推荐失败')
|
||||||
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
|
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
|
||||||
await load()
|
await load()
|
||||||
if (!plan.value) throw error
|
if (!plan.value) throw error
|
||||||
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
|
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
|
||||||
}
|
}
|
||||||
ElMessage.success('智能建议已生成,请人工核对后再推送')
|
ElMessage.success('智能建议已生成,请人工核对后再推送')
|
||||||
}
|
}
|
||||||
@@ -173,24 +283,34 @@ async function adjust(source: object) {
|
|||||||
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
|
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
|
||||||
catch (error) { await handleMutationError(error, '调整失败') }
|
catch (error) { await handleMutationError(error, '调整失败') }
|
||||||
finally { mutating.value = false }
|
finally { mutating.value = false }
|
||||||
}
|
}
|
||||||
async function remove(source: object) {
|
async function remove(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value || !item.canRemove) return
|
if (!plan.value || !item.canRemove) return
|
||||||
await ElMessageBox.confirm(`确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
const row = source as SmartLoadingPlanRow
|
||||||
|
const message = row.coLoadGroup
|
||||||
|
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||||
|
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
|
||||||
|
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try {
|
try {
|
||||||
|
if (row.coLoadGroup) {
|
||||||
|
await acceptPlanWithGroups(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
|
||||||
|
await load()
|
||||||
|
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
|
||||||
|
return
|
||||||
|
}
|
||||||
let current = plan.value!
|
let current = plan.value!
|
||||||
for (const original of item.sourceItems) {
|
for (const original of item.sourceItems) {
|
||||||
const currentItem = current.items.find((value) => value.id === original.id)
|
const currentItem = current.items.find((value) => value.id === original.id)
|
||||||
if (!currentItem) continue
|
if (!currentItem) continue
|
||||||
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
|
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
|
||||||
acceptPlan(current)
|
await acceptPlanWithGroups(current)
|
||||||
}
|
}
|
||||||
await load()
|
await load()
|
||||||
ElMessage.success('该入仓码已从装柜方案移除')
|
ElMessage.success('该入仓码已从装柜方案移除')
|
||||||
@@ -201,11 +321,11 @@ async function remove(source: object) {
|
|||||||
}
|
}
|
||||||
async function pushPreload() {
|
async function pushPreload() {
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
|
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') }
|
try { await acceptPlanWithGroups(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
|
||||||
catch (error) { await handleMutationError(error, '推送失败') }
|
catch (error) { await handleMutationError(error, '推送失败') }
|
||||||
finally { mutating.value = false }
|
finally { mutating.value = false }
|
||||||
}
|
}
|
||||||
@@ -265,22 +385,24 @@ onMounted(load)
|
|||||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust && !row.coLoadGroup" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />{{ row.coLoadGroup ? '移除整组' : '移除整票' }}</el-button><small v-if="row.coLoadGroup">整组固定全箱数,不允许单票调整</small><small v-else-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||||
|
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>全部入仓片段联动,固定 {{ row.coLoadGroup.fullPackageCount }} 箱</span></div><span v-else>普通单票</span></template>
|
||||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
|
||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
||||||
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="assignedInboundIds.has(row.inboundId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
|
||||||
|
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
|
||||||
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
|
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
|
||||||
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<AppDialog v-model="assignOpen" title="按入仓码分配箱数" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></div></AppDialog>
|
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
|
||||||
</section>
|
</section>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -306,8 +428,12 @@ onMounted(load)
|
|||||||
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
||||||
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
||||||
.row-actions { min-width: 150px; }
|
.row-actions { min-width: 150px; }
|
||||||
.workbench :deep(.el-input-number) { width: 130px; }
|
.package-count-editor { display: flex; min-width: 0; }
|
||||||
|
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||||
.assign-dialog p { margin: 0 0 10px; }
|
.assign-dialog p { margin: 0 0 10px; }
|
||||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||||
|
.candidate-group { display: grid; width: fit-content; gap: 3px; }
|
||||||
|
.candidate-group > span,.candidate-group > small { color: var(--cc-muted); font-size: 11px; }
|
||||||
|
.candidate-group .candidate-block { color: var(--el-color-danger); }
|
||||||
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
|
||||||
|
import type { SmartLoadingPlan } from './types'
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
|
||||||
|
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
|
||||||
|
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
|
||||||
|
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = {
|
||||||
|
id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2,
|
||||||
|
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null,
|
||||||
|
pdaTaskStatus: null, pdaTaskVersion: null,
|
||||||
|
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
|
||||||
|
} satisfies SmartLoadingPlan
|
||||||
|
|
||||||
|
describe('smart loading plan co-load groups', () => {
|
||||||
|
it('rebuilds the locked group from the current inbound association contract', async () => {
|
||||||
|
const lookup = vi.fn(async (inboundId: string) => ({
|
||||||
|
inboundId, inboundCode: `IN-${inboundId}`, loadingPlanId: '99', coLoadGroupId: '9001', coLoadGroupNo: 'CLG-9001', items: [],
|
||||||
|
}))
|
||||||
|
const result = await loadSmartLoadingPlanCoLoadGroups(plan, lookup)
|
||||||
|
expect(lookup).toHaveBeenCalledTimes(2)
|
||||||
|
expect(result.get('201')).toEqual({
|
||||||
|
groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12,
|
||||||
|
})
|
||||||
|
expect(result.get('202')).toBe(result.get('201'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves ordinary plan items ungrouped', async () => {
|
||||||
|
const result = await loadSmartLoadingPlanCoLoadGroups(plan, async (inboundId) => ({ inboundId, inboundCode: `IN-${inboundId}`, items: [] }))
|
||||||
|
expect(result.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { getLoadingAssociationSummaryByInboundId, type LoadingAssociationSummary } from '@/shared/api/inboundCodeRelations'
|
||||||
|
import { groupSmartLoadingItems } from './smartLoadingItems'
|
||||||
|
import type { SmartLoadingPlan } from './types'
|
||||||
|
|
||||||
|
export interface SmartLoadingPlanCoLoadGroup {
|
||||||
|
groupId: string
|
||||||
|
groupNo: string
|
||||||
|
lockedPlanId: string
|
||||||
|
memberOrderCount: number
|
||||||
|
inboundFragmentCount: number
|
||||||
|
fullPackageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssociationLookup = (inboundId: string) => Promise<LoadingAssociationSummary>
|
||||||
|
|
||||||
|
export async function loadSmartLoadingPlanCoLoadGroups(
|
||||||
|
plan: SmartLoadingPlan,
|
||||||
|
lookup: AssociationLookup = getLoadingAssociationSummaryByInboundId,
|
||||||
|
) {
|
||||||
|
const inboundRows = groupSmartLoadingItems(plan.items || [])
|
||||||
|
if (!inboundRows.length) return new Map<string, SmartLoadingPlanCoLoadGroup>()
|
||||||
|
|
||||||
|
const associations = await Promise.all(inboundRows.map(async (row) => ({
|
||||||
|
row,
|
||||||
|
association: await lookup(row.inboundId),
|
||||||
|
})))
|
||||||
|
const membersByGroupId = new Map<string, typeof associations>()
|
||||||
|
for (const entry of associations) {
|
||||||
|
const groupId = String(entry.association.coLoadGroupId || '')
|
||||||
|
if (!groupId) continue
|
||||||
|
membersByGroupId.set(groupId, [...(membersByGroupId.get(groupId) || []), entry])
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>()
|
||||||
|
for (const [groupId, members] of membersByGroupId) {
|
||||||
|
const group = {
|
||||||
|
groupId,
|
||||||
|
groupNo: members.find((entry) => entry.association.coLoadGroupNo)?.association.coLoadGroupNo || '同柜不可拆组',
|
||||||
|
lockedPlanId: members.find((entry) => entry.association.loadingPlanId)?.association.loadingPlanId || plan.id,
|
||||||
|
memberOrderCount: new Set(members.map((entry) => entry.row.orderId)).size,
|
||||||
|
inboundFragmentCount: members.length,
|
||||||
|
fullPackageCount: members.reduce((sum, entry) => sum + entry.row.packageCount, 0),
|
||||||
|
} satisfies SmartLoadingPlanCoLoadGroup
|
||||||
|
for (const { row } of members) groupsByInboundId.set(row.inboundId, group)
|
||||||
|
}
|
||||||
|
return groupsByInboundId
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export interface BusinessDocument {
|
|||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||||
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
|
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
|
||||||
|
|
||||||
export interface ContainerOverviewDocument {
|
export interface ContainerOverviewDocument {
|
||||||
@@ -149,8 +149,20 @@ export interface ContainerExceptionHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerTimelineEntry {
|
export interface ContainerTimelineEntry {
|
||||||
|
id?: string | null
|
||||||
|
eventId?: string | null
|
||||||
|
idempotencyKey?: string | null
|
||||||
eventType: string
|
eventType: string
|
||||||
|
action?: string | null
|
||||||
|
fromStatus?: string | null
|
||||||
|
toStatus?: string | null
|
||||||
detail: string | null
|
detail: string | null
|
||||||
|
summary?: string | null
|
||||||
|
changeSummary?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
changedFields?: unknown
|
||||||
|
fieldChanges?: unknown
|
||||||
|
changes?: unknown
|
||||||
operatorId: string | null
|
operatorId: string | null
|
||||||
operatorName: string | null
|
operatorName: string | null
|
||||||
occurredAt: string | null
|
occurredAt: string | null
|
||||||
@@ -236,6 +248,12 @@ export interface SmartLoadingCandidate {
|
|||||||
unitNetWeightKg: number | null
|
unitNetWeightKg: number | null
|
||||||
averageUnitVolumeCbm: number | null
|
averageUnitVolumeCbm: number | null
|
||||||
availableVolumeCbm: number | null
|
availableVolumeCbm: number | null
|
||||||
|
coLoadGroupId: string | null
|
||||||
|
groupNo: string | null
|
||||||
|
memberCount: number | null
|
||||||
|
groupTotalBoxes: number | null
|
||||||
|
locked: boolean | null
|
||||||
|
blockReason: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SmartLoadingItem {
|
export interface SmartLoadingItem {
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ const overseasPanelSource = readFileSync(resolve(process.cwd(), 'src/modules/cus
|
|||||||
|
|
||||||
vi.mock('@/shared/api/http', () => ({
|
vi.mock('@/shared/api/http', () => ({
|
||||||
http: { get: vi.fn(), put: vi.fn() },
|
http: { get: vi.fn(), put: vi.fn() },
|
||||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
unwrapApiResponse: <T>(response: { data: T }) => {
|
||||||
|
const value = response.data as T & { code?: string; message?: string; data?: unknown }
|
||||||
|
if (value && typeof value === 'object' && 'code' in value) {
|
||||||
|
if (value.code !== '200') throw new Error(value.message || 'request failed')
|
||||||
|
return value.data as T
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
|
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
|
||||||
@@ -46,6 +53,19 @@ describe('customer overseas storage fee api', () => {
|
|||||||
expect(result.version).toBe(1)
|
expect(result.version).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects a business error returned by the update endpoint', async () => {
|
||||||
|
vi.mocked(http.put).mockResolvedValue({ data: { code: 'A0400', message: '璇锋眰鏃犳晥', data: null } })
|
||||||
|
|
||||||
|
await expect(updateCustomerOverseasStorageFees(config.customerId, {
|
||||||
|
customerId: config.customerId,
|
||||||
|
customerName: '瀹㈡埛',
|
||||||
|
currency: 'USD',
|
||||||
|
version: 0,
|
||||||
|
tiers: [{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0 }],
|
||||||
|
})).rejects.toThrow('璇锋眰鏃犳晥')
|
||||||
|
expect(http.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('normalizes an omitted end day as an open-ended tier', async () => {
|
it('normalizes an omitted end day as an open-ended tier', async () => {
|
||||||
vi.mocked(http.get).mockResolvedValue({ data: { ...config, tiers: [{ id: 1, startDay: 91, unitPrice: 2, sortNo: 5, feeType: 'STORAGE', billingUnit: 'CBM' }] } })
|
vi.mocked(http.get).mockResolvedValue({ data: { ...config, tiers: [{ id: 1, startDay: 91, unitPrice: 2, sortNo: 5, feeType: 'STORAGE', billingUnit: 'CBM' }] } })
|
||||||
const result = await getCustomerOverseasStorageFees(config.customerId)
|
const result = await getCustomerOverseasStorageFees(config.customerId)
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export async function getCustomerOverseasStorageFees(tenantId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
|
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
|
||||||
await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
|
const response = await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
|
||||||
|
unwrapApiResponse(response)
|
||||||
return getCustomerOverseasStorageFees(tenantId)
|
return getCustomerOverseasStorageFees(tenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ describe('finance api', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('includes a supplier filter only for payable fee-pool requests', async () => {
|
||||||
|
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
|
||||||
|
|
||||||
|
await listFinanceFeePool('10001', 'PAYABLE', '301')
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-pool', {
|
||||||
|
params: { customerId: '10001', direction: 'PAYABLE', supplierId: '301' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves settlement mode from fee-pool candidates for client-side eligibility checks', async () => {
|
||||||
|
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{
|
||||||
|
poolItemId: 'PAYABLE:42', customerId: 10001, direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: 42,
|
||||||
|
sourceVersion: 1, feeItemName: '进仓费', amount: 33, currency: 'CNY', settlementMode: 'DIRECT_PAID',
|
||||||
|
status: 'AVAILABLE', createdAt: '2026-08-26T00:00:00',
|
||||||
|
}] })
|
||||||
|
|
||||||
|
const [candidate] = await listFinanceFeePool('10001', 'PAYABLE', '301')
|
||||||
|
|
||||||
|
expect(candidate.settlementMode).toBe('DIRECT_PAID')
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the formal G1 account list path and fixed page DTO', async () => {
|
it('uses the formal G1 account list path and fixed page DTO', async () => {
|
||||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
|
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
|
||||||
|
|
||||||
|
|||||||
@@ -111,12 +111,14 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
|
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE', supplierId?: string): Promise<FinanceFeePoolItem[]> {
|
||||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } })
|
const params: Record<string, unknown> = { customerId: customerId?.trim() || undefined, direction }
|
||||||
|
if (supplierId?.trim()) params.supplierId = supplierId.trim()
|
||||||
|
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params })
|
||||||
return unwrapApiResponse(response).map((item) => {
|
return unwrapApiResponse(response).map((item) => {
|
||||||
const poolItemId = stringValue(item.poolItemId)
|
const poolItemId = stringValue(item.poolItemId)
|
||||||
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
|
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
|
||||||
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
|
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', settlementMode: item.settlementMode == null ? null : stringValue(item.settlementMode), status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ export async function listFinanceAccounts(params: { page?: number; size?: number
|
|||||||
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined, status: params.status || undefined }
|
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined, status: params.status || undefined }
|
||||||
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
|
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
|
||||||
const data = unwrapApiResponse(response)
|
const data = unwrapApiResponse(response)
|
||||||
const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
|
const records: FinanceAccountSummary[] = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
|
||||||
const total = numberValue(response.page?.total ?? records.length)
|
const total = numberValue(response.page?.total ?? records.length)
|
||||||
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
|
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
|
||||||
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
|
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe('finance six-page delivery contract', () => {
|
|||||||
|
|
||||||
it('connects G1 customer accounts to the formal list and ledger operations', () => {
|
it('connects G1 customer accounts to the formal list and ledger operations', () => {
|
||||||
const page = source('src/modules/finance/pages/FinanceCustomerAccountsView.vue')
|
const page = source('src/modules/finance/pages/FinanceCustomerAccountsView.vue')
|
||||||
|
const types = source('src/modules/finance/types.ts')
|
||||||
expect(api).toContain('`${basePath}/accounts`')
|
expect(api).toContain('`${basePath}/accounts`')
|
||||||
expect(api).toContain('`${basePath}/accounts/${customerId}/ledger`')
|
expect(api).toContain('`${basePath}/accounts/${customerId}/ledger`')
|
||||||
expect(page).toContain('listFinanceAccounts')
|
expect(page).toContain('listFinanceAccounts')
|
||||||
@@ -62,6 +63,11 @@ describe('finance six-page delivery contract', () => {
|
|||||||
expect(page).toContain('options: customers.value.filter')
|
expect(page).toContain('options: customers.value.filter')
|
||||||
expect(page).toContain('label: item.tenantName')
|
expect(page).toContain('label: item.tenantName')
|
||||||
expect(page).not.toContain('tenantCode')
|
expect(page).not.toContain('tenantCode')
|
||||||
|
expect(types).toContain('export interface FinanceOperatingFundsSummary')
|
||||||
|
expect(types).toContain('export interface FinanceAccountSummary extends FinanceOperatingFundsSummary')
|
||||||
|
expect(api).toContain('const records: FinanceAccountSummary[]')
|
||||||
|
for (const metric of ['待收客户款', '待付供应商款', '已线下支付供应商款']) expect(page).toContain(metric)
|
||||||
|
for (const forbidden of ['客户线下支付', '已收客户款', '已付成本']) expect(page).not.toContain(forbidden)
|
||||||
const columns = page.slice(page.indexOf('const columns: DataTableColumn[]'))
|
const columns = page.slice(page.indexOf('const columns: DataTableColumn[]'))
|
||||||
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'customerName'"))
|
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'customerName'"))
|
||||||
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
|
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export { financeRoutes } from './routes'
|
export { financeRoutes } from './routes'
|
||||||
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
|
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
|
||||||
export { getFinanceCustomerAccountSummary, type FinanceCustomerAccountSummaryResource } from './api/finance'
|
export {
|
||||||
|
getFinanceCustomerAccountSummary,
|
||||||
|
getFinanceSettlement,
|
||||||
|
listFinancePayableSettlements,
|
||||||
|
listFinanceReceivableSettlements,
|
||||||
|
type FinanceCustomerAccountSummaryResource,
|
||||||
|
} from './api/finance'
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
|||||||
expect(page).toContain('返回客户账户')
|
expect(page).toContain('返回客户账户')
|
||||||
expect(page).toContain('<DetailPageLayout')
|
expect(page).toContain('<DetailPageLayout')
|
||||||
expect(page).toContain('class="account-metrics"')
|
expect(page).toContain('class="account-metrics"')
|
||||||
expect(page).toContain('待收金额')
|
for (const metric of ['待收客户款', '待付供应商款', '已线下支付供应商款']) expect(page).toContain(metric)
|
||||||
expect(page).toContain('待开账金额')
|
expect(page.match(/class="account-metric /g)).toHaveLength(3)
|
||||||
expect(page).toContain('class="ledger-query"')
|
expect(page).toContain('class="ledger-query"')
|
||||||
|
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
|
||||||
expect(page).toContain('class="ledger-table"')
|
expect(page).toContain('class="ledger-table"')
|
||||||
expect(page).toContain('<template #toolbar-actions>')
|
expect(page).toContain('<template #toolbar-actions>')
|
||||||
expect(page).toContain('<template #pagination-left>共 {{ ledgerTotal }} 条</template>')
|
expect(page).toContain('<template #pagination-left>共 {{ ledgerTotal }} 条</template>')
|
||||||
@@ -71,7 +72,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
|||||||
it('keeps term editing aligned with the account summary and nearby actions', () => {
|
it('keeps term editing aligned with the account summary and nearby actions', () => {
|
||||||
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
|
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
|
||||||
expect(page).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
|
expect(page).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
|
||||||
for (const metric of ['总成本(CNY)', '已付成本(CNY)', '总成本', '已付成本', '待付金额']) expect(page).toContain(metric)
|
for (const column of ['总成本(CNY)', '待收客户款(CNY)', '待付供应商款(CNY)', '已线下支付供应商款(CNY)']) expect(page).toContain(column)
|
||||||
|
expect(page).not.toContain('已付成本')
|
||||||
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
|
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
|
||||||
expect(page).toContain('justify-content: start')
|
expect(page).toContain('justify-content: start')
|
||||||
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
|
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
|
||||||
@@ -102,8 +104,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows only derived CNY summaries and no wallet actions', () => {
|
it('shows only derived CNY summaries and no wallet actions', () => {
|
||||||
for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker)
|
for (const marker of ['运营资金:待收客户款', '待付供应商款', '已线下支付供应商款', 'CNY']) expect(page).toContain(marker)
|
||||||
for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
|
for (const forbidden of ['客户线下支付', '已收客户款', '手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects an active customer by name from master data', () => {
|
it('selects an active customer by name from master data', () => {
|
||||||
@@ -150,6 +152,12 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
|||||||
expect(wrapper.text()).toContain('C-001')
|
expect(wrapper.text()).toContain('C-001')
|
||||||
expect(wrapper.text()).toContain('张三')
|
expect(wrapper.text()).toContain('张三')
|
||||||
expect(wrapper.text()).toContain('13800000000')
|
expect(wrapper.text()).toContain('13800000000')
|
||||||
|
const metrics = wrapper.find('.account-metrics')
|
||||||
|
expect(metrics.findAll('.account-metric')).toHaveLength(3)
|
||||||
|
expect(metrics.text()).toContain('待收客户款100.00CNY')
|
||||||
|
expect(metrics.text()).toContain('待付供应商款40.00CNY')
|
||||||
|
expect(metrics.text()).toContain('已线下支付供应商款50.00CNY')
|
||||||
|
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
|
||||||
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
|
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||||
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
|||||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||||
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
||||||
import { financeBusinessTypeLabel } from '../status'
|
import { financeBusinessTypeLabel } from '../status'
|
||||||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceOperatingFundsSummary } from '../types'
|
||||||
|
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -65,12 +65,12 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
|
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
|
||||||
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
|
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
|
||||||
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
|
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
|
||||||
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
|
{ key: 'outstandingReceivable', label: '待收客户款(CNY)', width: 155 },
|
||||||
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
||||||
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
||||||
{ key: 'totalCost', label: '总成本(CNY)', width: 140 },
|
{ key: 'totalCost', label: '总成本(CNY)', width: 140 },
|
||||||
{ key: 'paidCost', label: '已付成本(CNY)', width: 150 },
|
{ key: 'paidCost', label: '已线下支付供应商款(CNY)', width: 210 },
|
||||||
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
|
{ key: 'outstandingPayable', label: '待付供应商款(CNY)', width: 165 },
|
||||||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
||||||
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
|
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
|
||||||
]
|
]
|
||||||
@@ -87,14 +87,11 @@ const ledgerColumns: DataTableColumn[] = [
|
|||||||
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
||||||
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||||
]
|
]
|
||||||
const summary = computed(() => rows.value.reduce((result, row) => ({
|
const operatingFundsSummary = computed<FinanceOperatingFundsSummary>(() => rows.value.reduce((result, row) => ({
|
||||||
receivable: result.receivable + row.outstandingReceivable,
|
outstandingReceivable: result.outstandingReceivable + row.outstandingReceivable,
|
||||||
pending: result.pending + row.pendingBilling,
|
outstandingPayable: result.outstandingPayable + row.outstandingPayable,
|
||||||
received: result.received + row.receivedInPeriod,
|
|
||||||
totalCost: result.totalCost + row.totalCost,
|
|
||||||
paidCost: result.paidCost + row.paidCost,
|
paidCost: result.paidCost + row.paidCost,
|
||||||
payable: result.payable + row.outstandingPayable,
|
}), { outstandingReceivable: 0, outstandingPayable: 0, paidCost: 0 }))
|
||||||
}), { receivable: 0, pending: 0, received: 0, totalCost: 0, paidCost: 0, payable: 0 }))
|
|
||||||
|
|
||||||
async function load(model: QueryModel = filters.value) {
|
async function load(model: QueryModel = filters.value) {
|
||||||
Object.assign(filters.value, model)
|
Object.assign(filters.value, model)
|
||||||
@@ -267,7 +264,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
|||||||
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
|
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
|
||||||
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
||||||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
||||||
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 总成本 {{ money(summary.totalCost) }} · 已付成本 {{ money(summary.paidCost) }} · 待付 {{ money(summary.payable) }} CNY</span></template>
|
<template #pagination-left><span>运营资金:待收客户款 {{ money(operatingFundsSummary.outstandingReceivable) }} · 待付供应商款 {{ money(operatingFundsSummary.outstandingPayable) }} · 已线下支付供应商款 {{ money(operatingFundsSummary.paidCost) }} CNY</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
@@ -275,12 +272,12 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
|||||||
<section class="surface-panel finance-account-detail__surface">
|
<section class="surface-panel finance-account-detail__surface">
|
||||||
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
|
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
|
||||||
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
|
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
|
||||||
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric"><span>总成本</span><strong>{{ money(selected.totalCost) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>已付成本</span><strong>{{ money(selected.paidCost) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></template>
|
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户运营资金"><div class="account-metric is-receivable"><span>待收客户款</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付供应商款</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>已线下支付供应商款</span><strong>{{ money(selected.paidCost) }}</strong><small>CNY</small></div></section></template>
|
||||||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||||||
<template v-else-if="selected">
|
<template v-else-if="selected">
|
||||||
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
|
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
|
||||||
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
|
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
|
||||||
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
|
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
|
||||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
|
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
|
||||||
|
|||||||
@@ -129,6 +129,28 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
|||||||
expect(adjustFinanceFeeSource).not.toHaveBeenCalled()
|
expect(adjustFinanceFeeSource).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('closes the customer dropdown when the surrounding page is scrolled', async () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||||
|
expect(source).toContain("document.addEventListener('scroll', closeCustomerSelectOnScroll, true)")
|
||||||
|
expect(source).toContain('onVisibleChange: onCustomerSelectVisible')
|
||||||
|
expect(source).not.toContain('input?.blur()')
|
||||||
|
expect(source).toContain("class: 'finance-customer-select'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses sourceSystem to suppress Firstleg editing when the source type is not in the legacy fallback list', async () => {
|
||||||
|
const firstlegOtherType = { ...feeSummary, sourceType: 'ORDER_FEE', reviewStatus: 'DRAFT' as const }
|
||||||
|
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [firstlegOtherType], total: 1, pages: 1 })
|
||||||
|
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
|
||||||
|
await loadRows(wrapper)
|
||||||
|
|
||||||
|
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
|
||||||
|
expect(buttonLabels).not.toContain('缂栬緫')
|
||||||
|
const vm = wrapper.vm as unknown as { openMutation(row: Record<string, unknown>, mode: 'EDIT'): Promise<void> }
|
||||||
|
await vm.openMutation(firstlegOtherType as unknown as Record<string, unknown>, 'EDIT')
|
||||||
|
expect(getFinanceFeeSource).not.toHaveBeenCalled()
|
||||||
|
expect(editFinanceFeeSource).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('loads all customers by default and keeps explicit customer filtering', async () => {
|
it('loads all customers by default and keeps explicit customer filtering', async () => {
|
||||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:customer:list'])
|
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:customer:list'])
|
||||||
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, customerId: undefined, direction: undefined }))
|
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, customerId: undefined, direction: undefined }))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { FinanceFeeSourceDetail } from '../types'
|
|||||||
interface ArapRow {
|
interface ArapRow {
|
||||||
id: string
|
id: string
|
||||||
feeSourceId: string | null
|
feeSourceId: string | null
|
||||||
|
sourceSystem: string
|
||||||
customerId: string
|
customerId: string
|
||||||
customerName: string
|
customerName: string
|
||||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||||
@@ -53,7 +54,6 @@ const router = useRouter()
|
|||||||
const customers = ref<PlatformTenantResource[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
const customersLoading = ref(false)
|
const customersLoading = ref(false)
|
||||||
const customersError = ref<ApiErrorDetails | null>(null)
|
const customersError = ref<ApiErrorDetails | null>(null)
|
||||||
const customerSelectOpen = ref(false)
|
|
||||||
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
|
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||||||
const allRows = ref<ArapRow[]>([])
|
const allRows = ref<ArapRow[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -78,6 +78,7 @@ const page = ref(1)
|
|||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const exportBusy = ref(false)
|
const exportBusy = ref(false)
|
||||||
|
const customerSelectVisible = ref(false)
|
||||||
|
|
||||||
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
|
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
|
||||||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||||||
@@ -93,15 +94,6 @@ function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown
|
|||||||
}
|
}
|
||||||
const stateError = computed(() => customersError.value || loadError.value)
|
const stateError = computed(() => customersError.value || loadError.value)
|
||||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||||
function onCustomerSelectVisible(visible: boolean) {
|
|
||||||
customerSelectOpen.value = visible
|
|
||||||
}
|
|
||||||
function closeCustomerSelectOnScroll() {
|
|
||||||
if (!customerSelectOpen.value || typeof document === 'undefined') return
|
|
||||||
const input = document.querySelector('.finance-customer-select input') as HTMLInputElement | null
|
|
||||||
input?.blur()
|
|
||||||
customerSelectOpen.value = false
|
|
||||||
}
|
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
const queryFields = computed<QueryField[]>(() => [
|
||||||
{
|
{
|
||||||
key: 'customerId',
|
key: 'customerId',
|
||||||
@@ -114,10 +106,10 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
class: 'finance-customer-select',
|
class: 'finance-customer-select',
|
||||||
popperClass: 'finance-customer-arap-popper',
|
popperClass: 'finance-customer-arap-popper',
|
||||||
teleported: false,
|
teleported: false,
|
||||||
onVisibleChange: onCustomerSelectVisible,
|
|
||||||
filterable: true,
|
filterable: true,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
loading: customersLoading.value,
|
loading: customersLoading.value,
|
||||||
|
onVisibleChange: onCustomerSelectVisible,
|
||||||
},
|
},
|
||||||
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
|
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
|
||||||
},
|
},
|
||||||
@@ -128,6 +120,12 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
||||||
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
||||||
])
|
])
|
||||||
|
function onCustomerSelectVisible(visible: boolean) { customerSelectVisible.value = visible }
|
||||||
|
function closeCustomerSelectOnScroll() {
|
||||||
|
if (!customerSelectVisible.value) return
|
||||||
|
const active = document.activeElement
|
||||||
|
if (active instanceof HTMLElement) active.blur()
|
||||||
|
}
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||||
@@ -217,7 +215,7 @@ async function load(model: QueryModel = filters.value) {
|
|||||||
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
|
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
|
||||||
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
|
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
|
||||||
})
|
})
|
||||||
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
|
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, sourceSystem: item.sourceSystem, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
@@ -336,7 +334,7 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
|
|||||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||||
try {
|
try {
|
||||||
detail.value = await getFinanceFeeSource(feeSourceId)
|
detail.value = await getFinanceFeeSource(feeSourceId)
|
||||||
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
|
selected.value = { id: detail.value.poolItemId, feeSourceId, sourceSystem: detail.value.sourceSystem, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
|
||||||
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
|
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
|
||||||
finally { detailLoading.value = false }
|
finally { detailLoading.value = false }
|
||||||
}
|
}
|
||||||
@@ -348,9 +346,7 @@ onMounted(async () => {
|
|||||||
if (canListCustomers.value) await loadCustomers()
|
if (canListCustomers.value) await loadCustomers()
|
||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => document.removeEventListener('scroll', closeCustomerSelectOnScroll, true))
|
||||||
document.removeEventListener('scroll', closeCustomerSelectOnScroll, true)
|
|
||||||
})
|
|
||||||
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
|
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
|
||||||
if (!previous) return
|
if (!previous) return
|
||||||
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
|
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
|
||||||
|
|||||||
@@ -222,9 +222,38 @@ describe('FinanceCustomerStatementsView', () => {
|
|||||||
expect(vm.filteredRows).toHaveLength(1)
|
expect(vm.filteredRows).toHaveLength(1)
|
||||||
vm.filters.containerNo = 'CONT-99'
|
vm.filters.containerNo = 'CONT-99'
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
expect(vm.filteredRows).toHaveLength(1)
|
||||||
|
await vm.load({
|
||||||
|
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-99',
|
||||||
|
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
|
||||||
|
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
expect(vm.filteredRows).toHaveLength(0)
|
expect(vm.filteredRows).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps statement status filter options aligned with the list status column', async () => {
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
const vm = wrapper.vm as unknown as {
|
||||||
|
fields: Array<{ key: string; label: string; placeholder?: string; options?: Array<{ label: string; value: string }> }>
|
||||||
|
columns: Array<{ key: string; label: string }>
|
||||||
|
}
|
||||||
|
const statusField = vm.fields.find((field) => field.key === 'status')
|
||||||
|
const statusColumn = vm.columns.find((column) => column.key === 'status')
|
||||||
|
expect(statusField).toMatchObject({ label: '对账单状态', placeholder: '请选择对账单状态' })
|
||||||
|
expect(statusField?.options).toEqual([
|
||||||
|
{ label: '草稿', value: 'DRAFT' },
|
||||||
|
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||||
|
{ label: '已审核', value: 'APPROVED' },
|
||||||
|
{ label: '已驳回', value: 'REJECTED' },
|
||||||
|
{ label: '已出账', value: 'ISSUED' },
|
||||||
|
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||||
|
{ label: '已结清', value: 'PAID' },
|
||||||
|
{ label: '已作废', value: 'VOIDED' },
|
||||||
|
])
|
||||||
|
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status' })
|
||||||
|
})
|
||||||
|
|
||||||
it('creates a formal statement export task with only non-empty filters', async () => {
|
it('creates a formal statement export task with only non-empty filters', async () => {
|
||||||
const { wrapper } = await mountPage()
|
const { wrapper } = await mountPage()
|
||||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
|
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
||||||
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||||
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||||
|
|
||||||
interface AdjustmentRow {
|
interface AdjustmentRow {
|
||||||
@@ -47,6 +47,7 @@ const filters = ref<QueryModel>({
|
|||||||
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
||||||
settled: '', lastPaymentRange: [], createdRange: [],
|
settled: '', lastPaymentRange: [], createdRange: [],
|
||||||
})
|
})
|
||||||
|
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||||
const rows = ref<StatementRow[]>([])
|
const rows = ref<StatementRow[]>([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
@@ -99,10 +100,7 @@ const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((
|
|||||||
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||||||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||||||
const fields = computed<QueryField[]>(() => [
|
const fields = computed<QueryField[]>(() => [
|
||||||
{ key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '请选择审核状态', options: [
|
{ key: 'status', type: 'select', label: '对账单状态', width: 180, placeholder: '请选择对账单状态', options: financeBillStatusOptions },
|
||||||
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
|
|
||||||
{ label: '审核拒绝', value: 'REJECTED' }, { label: '已出账', value: 'ISSUED' }, { label: '已作废', value: 'VOIDED' },
|
|
||||||
] },
|
|
||||||
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
|
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
|
||||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||||
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
|
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
|
||||||
@@ -114,7 +112,7 @@ const fields = computed<QueryField[]>(() => [
|
|||||||
])
|
])
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
|
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
|
||||||
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
|
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
|
||||||
{ key: 'billNo', label: '对账单号', minWidth: 180 },
|
{ key: 'billNo', label: '对账单号', minWidth: 180 },
|
||||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||||
@@ -208,9 +206,9 @@ function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconcilia
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringFilter(key: string) { return String(filters.value[key] || '').trim().toLowerCase() }
|
function stringFilter(key: string) { return String(appliedFilters.value[key] || '').trim().toLowerCase() }
|
||||||
function inDateRange(value: string, key: string) {
|
function inDateRange(value: string, key: string) {
|
||||||
const range = filters.value[key]
|
const range = appliedFilters.value[key]
|
||||||
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
|
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
|
||||||
const date = value.slice(0, 10)
|
const date = value.slice(0, 10)
|
||||||
return date >= String(range[0]) && date <= String(range[1])
|
return date >= String(range[0]) && date <= String(range[1])
|
||||||
@@ -251,6 +249,7 @@ async function load(model?: QueryModel) {
|
|||||||
const hydrateDetails = model !== undefined
|
const hydrateDetails = model !== undefined
|
||||||
if (model) {
|
if (model) {
|
||||||
filters.value = { ...model }
|
filters.value = { ...model }
|
||||||
|
appliedFilters.value = { ...model }
|
||||||
page.value = 1
|
page.value = 1
|
||||||
}
|
}
|
||||||
rows.value = []
|
rows.value = []
|
||||||
@@ -260,7 +259,7 @@ async function load(model?: QueryModel) {
|
|||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const statements = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined)
|
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
|
||||||
const detailErrors: ApiErrorDetails[] = []
|
const detailErrors: ApiErrorDetails[] = []
|
||||||
const hydrated = await Promise.all(statements.map(async (row) => {
|
const hydrated = await Promise.all(statements.map(async (row) => {
|
||||||
if (!hydrateDetails) return detailToRow(row)
|
if (!hydrateDetails) return detailToRow(row)
|
||||||
@@ -510,7 +509,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
|||||||
try {
|
try {
|
||||||
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
|
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
|
||||||
ElMessage.success('对账单状态已更新')
|
ElMessage.success('对账单状态已更新')
|
||||||
await load(filters.value)
|
await load(appliedFilters.value)
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
|
||||||
finally { actionBusy.value = false }
|
finally { actionBusy.value = false }
|
||||||
}
|
}
|
||||||
@@ -540,7 +539,7 @@ async function submitAdjustment() {
|
|||||||
})
|
})
|
||||||
adjustOpen.value = false
|
adjustOpen.value = false
|
||||||
ElMessage.success('对账单调整已保存')
|
ElMessage.success('对账单调整已保存')
|
||||||
await load(filters.value)
|
await load(appliedFilters.value)
|
||||||
await openDetail(statement)
|
await openDetail(statement)
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
||||||
finally { adjustBusy.value = false }
|
finally { adjustBusy.value = false }
|
||||||
@@ -648,7 +647,7 @@ watch(detailRouteId, (statementId) => {
|
|||||||
</el-tabs>
|
</el-tabs>
|
||||||
</DetailPageLayout>
|
</DetailPageLayout>
|
||||||
<ListPageLayout v-else aria-label="客户对账单">
|
<ListPageLayout v-else aria-label="客户对账单">
|
||||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
|
||||||
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
||||||
<template v-if="!customerError && !error">
|
<template v-if="!customerError && !error">
|
||||||
<section class="surface-panel table-panel">
|
<section class="surface-panel table-panel">
|
||||||
|
|||||||
@@ -61,4 +61,8 @@ describe('formal settlement field contract', () => {
|
|||||||
expect(pageSource).not.toContain('prop="sourceVersion" label="来源版本"')
|
expect(pageSource).not.toContain('prop="sourceVersion" label="来源版本"')
|
||||||
expect(pageSource).not.toContain('{{ detail.version }}')
|
expect(pageSource).not.toContain('{{ detail.version }}')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not offer directly paid costs as payable settlement sources', () => {
|
||||||
|
expect(pageSource).toContain("item.settlementMode?.toUpperCase() !== 'DIRECT_PAID'")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const router = useRouter()
|
|||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const customers = ref<PlatformTenantResource[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
|
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
|
||||||
|
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||||
const rows = ref<SettlementRow[]>([])
|
const rows = ref<SettlementRow[]>([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
@@ -113,9 +114,9 @@ const fields = computed<QueryField[]>(() => [
|
|||||||
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
|
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
|
||||||
])
|
])
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
const status = String(filters.value.status || '')
|
const status = String(appliedFilters.value.status || '')
|
||||||
const settlementNo = String(filters.value.settlementNo || '').trim().toLowerCase()
|
const settlementNo = String(appliedFilters.value.settlementNo || '').trim().toLowerCase()
|
||||||
const supplierName = String(filters.value.supplierName || '')
|
const supplierName = String(appliedFilters.value.supplierName || '')
|
||||||
return rows.value.filter((item) => (!status || item.status === status)
|
return rows.value.filter((item) => (!status || item.status === status)
|
||||||
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
|
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
|
||||||
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
|
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
|
||||||
@@ -233,13 +234,14 @@ async function loadCustomers() {
|
|||||||
async function load(model?: QueryModel) {
|
async function load(model?: QueryModel) {
|
||||||
if (model) {
|
if (model) {
|
||||||
filters.value = { ...model }
|
filters.value = { ...model }
|
||||||
|
appliedFilters.value = { ...model }
|
||||||
page.value = 1
|
page.value = 1
|
||||||
}
|
}
|
||||||
rows.value = []
|
rows.value = []
|
||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const customerId = String(filters.value.customerId || '').trim() || undefined
|
const customerId = String(appliedFilters.value.customerId || '').trim() || undefined
|
||||||
rows.value = props.kind === 'receivable'
|
rows.value = props.kind === 'receivable'
|
||||||
? await listFinanceReceivableSettlements(customerId)
|
? await listFinanceReceivableSettlements(customerId)
|
||||||
: await listFinancePayableSettlements(customerId)
|
: await listFinancePayableSettlements(customerId)
|
||||||
@@ -304,8 +306,13 @@ async function refreshCreateDependencies(customerId?: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE')
|
const selectedSupplierId = String(createForm.value.supplierId || '').trim() || undefined
|
||||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
|
if (!selectedSupplierId) {
|
||||||
|
createForm.value.feeSourceIds = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE', selectedSupplierId)
|
||||||
|
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE' && item.settlementMode?.toUpperCase() !== 'DIRECT_PAID')
|
||||||
if (createForm.value.supplierId && !supplierOptions.value.find((supplier) => String(supplier.id) === String(createForm.value.supplierId))) {
|
if (createForm.value.supplierId && !supplierOptions.value.find((supplier) => String(supplier.id) === String(createForm.value.supplierId))) {
|
||||||
createForm.value.supplierId = ''
|
createForm.value.supplierId = ''
|
||||||
}
|
}
|
||||||
@@ -321,6 +328,12 @@ async function refreshCreateDependencies(customerId?: string) {
|
|||||||
watch(() => createForm.value.customerId, (customerId) => {
|
watch(() => createForm.value.customerId, (customerId) => {
|
||||||
if (createOpen.value) void refreshCreateDependencies(customerId)
|
if (createOpen.value) void refreshCreateDependencies(customerId)
|
||||||
})
|
})
|
||||||
|
watch(() => createForm.value.supplierId, () => {
|
||||||
|
if (!createOpen.value) return
|
||||||
|
createForm.value.feeSourceIds = []
|
||||||
|
feeOptions.value = []
|
||||||
|
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId)
|
||||||
|
})
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
const customerId = String(createForm.value.customerId || '')
|
const customerId = String(createForm.value.customerId || '')
|
||||||
@@ -580,7 +593,7 @@ onMounted(() => {
|
|||||||
</DetailPageLayout>
|
</DetailPageLayout>
|
||||||
<ListPageLayout v-else :aria-label="title">
|
<ListPageLayout v-else :aria-label="title">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" />
|
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="customerError || error" #state>
|
<template v-if="customerError || error" #state>
|
||||||
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
|
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('Finance G3/G4 views', () => {
|
|||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
||||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
|
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
|
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
|
||||||
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
|
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
|
||||||
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
|
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
|
||||||
@@ -125,6 +125,25 @@ describe('Finance G3/G4 views', () => {
|
|||||||
expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 }))
|
expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads payable sources only for the selected supplier and clears them when the supplier changes', async () => {
|
||||||
|
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create'], { kind: 'payable' })
|
||||||
|
const vm = wrapper.vm as unknown as { openCreate(): Promise<void>; createForm: { customerId: string; supplierId: string; feeSourceIds: string[] } }
|
||||||
|
|
||||||
|
await vm.openCreate()
|
||||||
|
vm.createForm.customerId = '10001'
|
||||||
|
await flushPromises()
|
||||||
|
expect(listFinanceFeePool).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
vm.createForm.supplierId = '301'
|
||||||
|
await flushPromises()
|
||||||
|
expect(listFinanceFeePool).toHaveBeenCalledWith('10001', 'PAYABLE', '301')
|
||||||
|
|
||||||
|
vm.createForm.feeSourceIds = ['81']
|
||||||
|
vm.createForm.supplierId = ''
|
||||||
|
await flushPromises()
|
||||||
|
expect(vm.createForm.feeSourceIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('renders request and trace ids when G3 detail loading fails', async () => {
|
it('renders request and trace ids when G3 detail loading fails', async () => {
|
||||||
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error'))
|
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error'))
|
||||||
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list'])
|
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list'])
|
||||||
@@ -149,4 +168,19 @@ describe('Finance G3/G4 views', () => {
|
|||||||
expect(listFinanceReceivableSettlements).toHaveBeenLastCalledWith('10001')
|
expect(listFinanceReceivableSettlements).toHaveBeenLastCalledWith('10001')
|
||||||
expect(listFinancePayableSettlements).toHaveBeenLastCalledWith('10001')
|
expect(listFinancePayableSettlements).toHaveBeenLastCalledWith('10001')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies settlement list text filters only after an explicit query', async () => {
|
||||||
|
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list'], { kind: 'payable' })
|
||||||
|
const vm = wrapper.vm as unknown as {
|
||||||
|
filters: Record<string, unknown>
|
||||||
|
filteredRows: FinancePayableSettlement[]
|
||||||
|
load(model: Record<string, unknown>): Promise<void>
|
||||||
|
}
|
||||||
|
vm.filters.settlementNo = 'NOT-FOUND'
|
||||||
|
await flushPromises()
|
||||||
|
expect(vm.filteredRows).toHaveLength(1)
|
||||||
|
await vm.load({ customerId: '', supplierName: '', settlementNo: 'NOT-FOUND', status: '' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(vm.filteredRows).toHaveLength(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ describe('finance presentation governance', () => {
|
|||||||
expect(arap).toContain("placeholder: '请选择客户'")
|
expect(arap).toContain("placeholder: '请选择客户'")
|
||||||
expect(arap).not.toContain("placeholder: '全部客户'")
|
expect(arap).not.toContain("placeholder: '全部客户'")
|
||||||
expect(statements).toContain("placeholder: '请选择客户'")
|
expect(statements).toContain("placeholder: '请选择客户'")
|
||||||
expect(statements).toContain("placeholder: '请选择审核状态'")
|
expect(statements).toContain("placeholder: '请选择对账单状态'")
|
||||||
|
expect(statements).toContain(':search-on-clear="false"')
|
||||||
expect(statements).toContain("placeholder: '请选择结清状态'")
|
expect(statements).toContain("placeholder: '请选择结清状态'")
|
||||||
expect(statements).toContain("startPlaceholder: '请选择开始日期'")
|
expect(statements).toContain("startPlaceholder: '请选择开始日期'")
|
||||||
expect(statements).toContain("endPlaceholder: '请选择结束日期'")
|
expect(statements).toContain("endPlaceholder: '请选择结束日期'")
|
||||||
expect(statements).not.toContain("placeholder: '全部客户'")
|
expect(statements).not.toContain("placeholder: '全部客户'")
|
||||||
|
expect(settlement).toContain(':search-on-clear="false"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses active-customer name selectors across all formal finance pages', () => {
|
it('uses active-customer name selectors across all formal finance pages', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
|
import { financeArapStatusOptions, financeBillStatusOptions, financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
|
||||||
|
|
||||||
describe('finance status presentation', () => {
|
describe('finance status presentation', () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -47,4 +47,26 @@ describe('finance status presentation', () => {
|
|||||||
expect(financeDocumentTypeLabel('NEW_DOCUMENT')).toBe('未知单据类型')
|
expect(financeDocumentTypeLabel('NEW_DOCUMENT')).toBe('未知单据类型')
|
||||||
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
|
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses review-status values for the customer AR/AP review filter', () => {
|
||||||
|
expect(financeArapStatusOptions).toEqual([
|
||||||
|
{ label: '草稿', value: 'DRAFT' },
|
||||||
|
{ label: '待审核', value: 'PENDING' },
|
||||||
|
{ label: '已审核', value: 'APPROVED' },
|
||||||
|
{ label: '已驳回', value: 'REJECTED' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the complete reconciliation statement lifecycle for statement filters', () => {
|
||||||
|
expect(financeBillStatusOptions).toEqual([
|
||||||
|
{ label: '草稿', value: 'DRAFT' },
|
||||||
|
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||||
|
{ label: '已审核', value: 'APPROVED' },
|
||||||
|
{ label: '已驳回', value: 'REJECTED' },
|
||||||
|
{ label: '已出账', value: 'ISSUED' },
|
||||||
|
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||||
|
{ label: '已结清', value: 'PAID' },
|
||||||
|
{ label: '已作废', value: 'VOIDED' },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,20 +31,19 @@ const financeStatuses: Record<string, FinanceStatusDefinition> = {
|
|||||||
export const financeBillStatusOptions = [
|
export const financeBillStatusOptions = [
|
||||||
{ label: '草稿', value: 'DRAFT' },
|
{ label: '草稿', value: 'DRAFT' },
|
||||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||||
{ label: '已出账', value: 'ISSUED' },
|
{ label: '已审核', value: 'APPROVED' },
|
||||||
{ label: '已驳回', value: 'REJECTED' },
|
{ label: '已驳回', value: 'REJECTED' },
|
||||||
|
{ label: '已出账', value: 'ISSUED' },
|
||||||
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||||
{ label: '已结清', value: 'PAID' },
|
{ label: '已结清', value: 'PAID' },
|
||||||
{ label: '已作废', value: 'VOIDED' },
|
{ label: '已作废', value: 'VOIDED' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const financeArapStatusOptions = [
|
export const financeArapStatusOptions = [
|
||||||
...financeBillStatusOptions,
|
{ label: '草稿', value: 'DRAFT' },
|
||||||
{ label: '待结算', value: 'AVAILABLE' },
|
{ label: '待审核', value: 'PENDING' },
|
||||||
{ label: '结算锁定', value: 'LOCKED' },
|
{ label: '已审核', value: 'APPROVED' },
|
||||||
{ label: '已入账', value: 'BILLED' },
|
{ label: '已驳回', value: 'REJECTED' },
|
||||||
{ label: '已登记', value: 'RECORDED' },
|
|
||||||
{ label: '已确认', value: 'CONFIRMED' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function normalizeStatus(status: unknown) {
|
function normalizeStatus(status: unknown) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
export type FinanceBillStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||||
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
|
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
|
||||||
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||||
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||||
@@ -59,6 +59,7 @@ export interface FinanceFeePoolItem {
|
|||||||
feeItemName: string
|
feeItemName: string
|
||||||
amount: number
|
amount: number
|
||||||
currency: 'CNY'
|
currency: 'CNY'
|
||||||
|
settlementMode?: string | null
|
||||||
status: string
|
status: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
@@ -174,18 +175,21 @@ export interface FinancePayableSettlement {
|
|||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceAccountSummary {
|
export interface FinanceOperatingFundsSummary {
|
||||||
|
outstandingReceivable: number
|
||||||
|
outstandingPayable: number
|
||||||
|
paidCost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceAccountSummary extends FinanceOperatingFundsSummary {
|
||||||
customerId: string
|
customerId: string
|
||||||
customerCode: string
|
customerCode: string
|
||||||
customerName: string
|
customerName: string
|
||||||
contactName: string
|
contactName: string
|
||||||
contactPhone: string
|
contactPhone: string
|
||||||
outstandingReceivable: number
|
|
||||||
pendingBilling: number
|
pendingBilling: number
|
||||||
receivedInPeriod: number
|
receivedInPeriod: number
|
||||||
totalCost: number
|
totalCost: number
|
||||||
paidCost: number
|
|
||||||
outstandingPayable: number
|
|
||||||
status: string
|
status: string
|
||||||
lastChangedAt: string
|
lastChangedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,12 +153,12 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
expect(document.body.textContent).toContain('确认添加')
|
expect(document.body.textContent).toContain('确认添加')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a shared receiving warehouse for WAREHOUSE_PICKER', async () => {
|
it('requires a shared receiving warehouse for OVERSEAS_WAREHOUSE_PDA_OPERATOR', async () => {
|
||||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
roles: [
|
roles: [
|
||||||
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
||||||
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
|
{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
@@ -180,16 +180,16 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses the stable role code instead of localized role names to activate the warehouse binding', () => {
|
it('uses the stable role code instead of localized role names to activate the warehouse binding', () => {
|
||||||
expect(internalUsersSource).toContain("role.roleCode === 'WAREHOUSE_PICKER'")
|
expect(internalUsersSource).toContain("role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'")
|
||||||
expect(internalUsersSource).toContain('overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null')
|
expect(internalUsersSource).toContain('overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null')
|
||||||
expect(internalUsersSource).not.toContain("role.name === '仓库拣货员'")
|
expect(internalUsersSource).not.toContain("role.name === '海外仓 PDA 作业员'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves the picker warehouse binding when editing a WAREHOUSE_PICKER user', async () => {
|
it('preserves the picker warehouse binding when editing a OVERSEAS_WAREHOUSE_PDA_OPERATOR user', async () => {
|
||||||
const pickerUser: PlatformManagedUser = {
|
const pickerUser: PlatformManagedUser = {
|
||||||
...user,
|
...user,
|
||||||
roleIds: ['4000005'],
|
roleIds: ['4000005'],
|
||||||
roleNames: ['仓库拣货员'],
|
roleNames: ['海外仓 PDA 作业员'],
|
||||||
overseasWarehouseId: '90071992547409931',
|
overseasWarehouseId: '90071992547409931',
|
||||||
}
|
}
|
||||||
vi.mocked(listPlatformManagedUsers).mockResolvedValueOnce({
|
vi.mocked(listPlatformManagedUsers).mockResolvedValueOnce({
|
||||||
@@ -197,7 +197,7 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
})
|
})
|
||||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
|
roles: [{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' }],
|
||||||
})
|
})
|
||||||
vi.mocked(updatePlatformManagedUser).mockResolvedValueOnce(pickerUser)
|
vi.mocked(updatePlatformManagedUser).mockResolvedValueOnce(pickerUser)
|
||||||
|
|
||||||
@@ -220,12 +220,12 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears a warehouse binding after switching away from WAREHOUSE_PICKER', async () => {
|
it('clears a warehouse binding after switching away from OVERSEAS_WAREHOUSE_PDA_OPERATOR', async () => {
|
||||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
roles: [
|
roles: [
|
||||||
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
||||||
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
|
{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
@@ -250,7 +250,7 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
it('submits the selected picker warehouse as an unchanged long-id string', async () => {
|
it('submits the selected picker warehouse as an unchanged long-id string', async () => {
|
||||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
|
roles: [{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' }],
|
||||||
})
|
})
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
const setupState = (wrapper.vm.$ as unknown as {
|
const setupState = (wrapper.vm.$ as unknown as {
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ const availableRoles = computed(() => options.value.roles.filter((role) =>
|
|||||||
String(role.tenantId) === '0'
|
String(role.tenantId) === '0'
|
||||||
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
||||||
))
|
))
|
||||||
const warehousePickerRoleIds = computed(() => new Set(
|
const overseasPdaRoleIds = computed(() => new Set(
|
||||||
availableRoles.value.filter((role) => role.roleCode === 'WAREHOUSE_PICKER').map((role) => String(role.id)),
|
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
|
||||||
))
|
))
|
||||||
const hasWarehousePickerRole = computed(() => form.roleIds.some((roleId) => warehousePickerRoleIds.value.has(roleId)))
|
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
|
||||||
|
|
||||||
const formRules = computed<FormRules>(() => ({
|
const formRules = computed<FormRules>(() => ({
|
||||||
username: editingUser.value ? [] : [
|
username: editingUser.value ? [] : [
|
||||||
@@ -122,7 +122,7 @@ const formRules = computed<FormRules>(() => ({
|
|||||||
],
|
],
|
||||||
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||||
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
||||||
overseasWarehouseId: !hasWarehousePickerRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
||||||
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
|
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
|
||||||
],
|
],
|
||||||
password: editingUser.value && !form.resetPassword ? [] : [
|
password: editingUser.value && !form.resetPassword ? [] : [
|
||||||
@@ -263,7 +263,7 @@ async function saveUser() {
|
|||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
tenantId: '0',
|
tenantId: '0',
|
||||||
roleIds: form.roleIds,
|
roleIds: form.roleIds,
|
||||||
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
|
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
})
|
})
|
||||||
@@ -277,7 +277,7 @@ async function saveUser() {
|
|||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
tenantId: '0',
|
tenantId: '0',
|
||||||
roleIds: form.roleIds,
|
roleIds: form.roleIds,
|
||||||
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
|
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (canChangeStatus.value && form.status !== row.status) {
|
if (canChangeStatus.value && form.status !== row.status) {
|
||||||
@@ -330,7 +330,7 @@ function applyFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch([page, pageSize], () => void loadUsers())
|
watch([page, pageSize], () => void loadUsers())
|
||||||
watch(hasWarehousePickerRole, (selected) => {
|
watch(hasOverseasPdaRole, (selected) => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
form.overseasWarehouseId = ''
|
form.overseasWarehouseId = ''
|
||||||
overseasWarehousesError.value = null
|
overseasWarehousesError.value = null
|
||||||
@@ -419,7 +419,7 @@ onMounted(loadInitialData)
|
|||||||
<el-option label="禁用" value="DISABLED" />
|
<el-option label="禁用" value="DISABLED" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="hasWarehousePickerRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
<el-form-item v-if="hasOverseasPdaRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
||||||
<el-select v-model="form.overseasWarehouseId" filterable
|
<el-select v-model="form.overseasWarehouseId" filterable
|
||||||
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||||
placeholder="请选择平台共享海外收货仓">
|
placeholder="请选择平台共享海外收货仓">
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ describe('InboundTaskList measurement scope', () => {
|
|||||||
expect(source).toContain('批量审核通过')
|
expect(source).toContain('批量审核通过')
|
||||||
expect(source).toContain('getMeasurementReviewDetail')
|
expect(source).toContain('getMeasurementReviewDetail')
|
||||||
expect(source).toContain('approveMeasurement')
|
expect(source).toContain('approveMeasurement')
|
||||||
|
expect(source).toContain('expectedVersion: detail.inboundVersion')
|
||||||
|
expect(source).not.toContain('expectedVersion: detail.version')
|
||||||
expect(source).toContain('approval.initialPricingSucceeded')
|
expect(source).toContain('approval.initialPricingSucceeded')
|
||||||
expect(source).toContain('费用初算未生成,可补价后重新计算')
|
expect(source).toContain('费用初算未生成,可补价后重新计算')
|
||||||
})
|
})
|
||||||
@@ -65,6 +67,15 @@ describe('InboundTaskList measurement scope', () => {
|
|||||||
expect(queryFieldsSource).not.toContain('客户编码')
|
expect(queryFieldsSource).not.toContain('客户编码')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears date conditions safely and uses explicit select placeholders', () => {
|
||||||
|
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||||
|
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const columns'))
|
||||||
|
|
||||||
|
expect(source).toContain('Array.isArray(filters.value.expectedArrivalRange)')
|
||||||
|
expect(queryFieldsSource).toContain("placeholder: customerLoadError.value ? '客户列表加载失败' : '请选择客户'")
|
||||||
|
expect(queryFieldsSource).toContain("placeholder: '请选择仓库'")
|
||||||
|
})
|
||||||
|
|
||||||
it('requests every active-customer page and merges name-only options', async () => {
|
it('requests every active-customer page and merges name-only options', async () => {
|
||||||
const calls: Array<{ page: number; size: number; status: 'ACTIVE' }> = []
|
const calls: Array<{ page: number; size: number; status: 'ACTIVE' }> = []
|
||||||
const records = await loadAllActiveCustomers(async (query) => {
|
const records = await loadAllActiveCustomers(async (query) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { CircleCheckBig, Eye, Ruler, SearchCheck } from '@lucide/vue'
|
import { CircleCheckBig, Eye, Link2, Ruler, SearchCheck } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||||
@@ -12,6 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { listPlatformInbounds } from '../core/api'
|
import { listPlatformInbounds } from '../core/api'
|
||||||
import type { InboundOrderSummary } from '../core/types'
|
import type { InboundOrderSummary } from '../core/types'
|
||||||
@@ -44,6 +45,8 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
|||||||
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
||||||
const measurementDialogOpen = ref(false)
|
const measurementDialogOpen = ref(false)
|
||||||
const reviewDialogOpen = ref(false)
|
const reviewDialogOpen = ref(false)
|
||||||
|
const relationshipDialogOpen = ref(false)
|
||||||
|
const relationshipDialogInboundCode = ref('')
|
||||||
const batchReviewLoading = ref(false)
|
const batchReviewLoading = ref(false)
|
||||||
const selectedReviewRows = ref<InboundOrderSummary[]>([])
|
const selectedReviewRows = ref<InboundOrderSummary[]>([])
|
||||||
let requestSerial = 0
|
let requestSerial = 0
|
||||||
@@ -65,14 +68,14 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
label: '客户',
|
label: '客户',
|
||||||
width: 260,
|
width: 260,
|
||||||
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
|
placeholder: customerLoadError.value ? '客户列表加载失败' : '请选择客户',
|
||||||
options: customers.value.map((customer) => ({
|
options: customers.value.map((customer) => ({
|
||||||
value: String(customer.id),
|
value: String(customer.id),
|
||||||
label: customer.legalName?.trim() || customer.tenantName,
|
label: customer.legalName?.trim() || customer.tenantName,
|
||||||
})),
|
})),
|
||||||
props: { filterable: true, disabled: Boolean(customerLoadError.value) },
|
props: { filterable: true, disabled: Boolean(customerLoadError.value) },
|
||||||
},
|
},
|
||||||
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
|
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '请选择仓库', options: warehouseOptions.value, props: { filterable: true } },
|
||||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
|
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
|
||||||
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
||||||
])
|
])
|
||||||
@@ -164,7 +167,10 @@ async function loadTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
appliedFilters.value = { ...filters.value, expectedArrivalRange: [...filters.value.expectedArrivalRange] }
|
appliedFilters.value = {
|
||||||
|
...filters.value,
|
||||||
|
expectedArrivalRange: Array.isArray(filters.value.expectedArrivalRange) ? [...filters.value.expectedArrivalRange] : [],
|
||||||
|
}
|
||||||
if (page.value === 1) void loadTasks()
|
if (page.value === 1) void loadTasks()
|
||||||
else page.value = 1
|
else page.value = 1
|
||||||
}
|
}
|
||||||
@@ -187,6 +193,13 @@ function openDetail(row: InboundOrderSummary) {
|
|||||||
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
|
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRelationship(row: InboundOrderSummary) {
|
||||||
|
const code = row.inboundCode?.trim()
|
||||||
|
if (!code) return
|
||||||
|
relationshipDialogInboundCode.value = code
|
||||||
|
relationshipDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function handleTaskChanged() {
|
function handleTaskChanged() {
|
||||||
reviewDialogOpen.value = false
|
reviewDialogOpen.value = false
|
||||||
selectedInbound.value = null
|
selectedInbound.value = null
|
||||||
@@ -216,17 +229,17 @@ async function batchApproveMeasurements() {
|
|||||||
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
|
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
|
||||||
const approval = await approveMeasurement(String(row.tenantId), String(row.id), {
|
const approval = await approveMeasurement(String(row.tenantId), String(row.id), {
|
||||||
measurementId: detail.measurementId,
|
measurementId: detail.measurementId,
|
||||||
expectedVersion: detail.version,
|
expectedVersion: detail.inboundVersion,
|
||||||
})
|
})
|
||||||
successCount += 1
|
successCount += 1
|
||||||
if (!approval.initialPricingSucceeded) {
|
if (!approval.initialPricingSucceeded) {
|
||||||
pricingWarnings.push(
|
pricingWarnings.push(
|
||||||
`${row.inboundCode || row.id}:${approval.initialPricingMessage || '费用初算未生成'}`,
|
`${row.inboundCode || '入仓码待补充'}:${approval.initialPricingMessage || '费用初算未生成'}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failureCount += 1
|
failureCount += 1
|
||||||
failures.push(getApiErrorDetails(error, `${row.inboundCode || row.id} 审核失败`).message)
|
failures.push(getApiErrorDetails(error, `${row.inboundCode || '入仓码待补充'} 审核失败`).message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
@@ -326,7 +339,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
|
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #inboundCode="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
|
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template>
|
||||||
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
||||||
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
||||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||||
@@ -349,7 +362,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-if="isReview"
|
v-if="isReview"
|
||||||
v-model="reviewDialogOpen"
|
v-model="reviewDialogOpen"
|
||||||
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
|
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || '入仓码待补充'}` : taskLabel"
|
||||||
width="min(1180px, calc(100vw - 32px))"
|
width="min(1180px, calc(100vw - 32px))"
|
||||||
top="4vh"
|
top="4vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@@ -378,6 +391,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
@submitted="handleMeasurementSubmitted"
|
@submitted="handleMeasurementSubmitted"
|
||||||
@closed="clearSelectedInbound"
|
@closed="clearSelectedInbound"
|
||||||
/>
|
/>
|
||||||
|
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -388,6 +402,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
.row-actions .el-button { margin: 0; }
|
.row-actions .el-button { margin: 0; }
|
||||||
.primary-cell { display: grid; gap: 3px; min-width: 0; }
|
.primary-cell { display: grid; gap: 3px; min-width: 0; }
|
||||||
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
|
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
|
||||||
|
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
|
||||||
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
|
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.row-actions { align-items: stretch; flex-direction: column; }
|
.row-actions { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const revision = ref(0)
|
|||||||
let requestSerial = 0
|
let requestSerial = 0
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|
||||||
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
|
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
|
||||||
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||||
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
|
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ describe('InboundMeasurementDialog', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows the inbound code once in the dialog title and not as a second body heading', () => {
|
it('shows the inbound code once in the dialog title and not as a second body heading', () => {
|
||||||
expect(source).toContain('`测量 · ${props.inboundCode?.trim() || props.inboundId}`')
|
expect(source).toContain("`测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`")
|
||||||
|
expect(source).not.toContain('props.inboundCode?.trim() || props.inboundId')
|
||||||
expect(source).toContain(':title="title"')
|
expect(source).toContain(':title="title"')
|
||||||
expect(panelSource).not.toContain('<span>入仓码</span>')
|
expect(panelSource).not.toContain('<span>入仓码</span>')
|
||||||
expect(panelSource).not.toContain('<strong>{{ detail.inboundCode')
|
expect(panelSource).not.toContain('<strong>{{ detail.inboundCode')
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
|
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
|
||||||
const title = computed(() => `测量 · ${props.inboundCode?.trim() || props.inboundId}`)
|
const title = computed(() => `测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`)
|
||||||
|
|
||||||
async function mayClose() {
|
async function mayClose() {
|
||||||
if (!measurementPanelRef.value?.isDirty) return true
|
if (!measurementPanelRef.value?.isDirty) return true
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '
|
|||||||
|
|
||||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||||
key: 'p2-inbound-measurement-editor',
|
key: 'p2-inbound-measurement-editor',
|
||||||
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
|
label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`),
|
||||||
isOpen: registrationOpen,
|
isOpen: registrationOpen,
|
||||||
model: form,
|
model: form,
|
||||||
requiredPermission,
|
requiredPermission,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Link2, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
@@ -10,6 +10,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
@@ -42,6 +43,8 @@ const pageSize = ref(20)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const printingInboundId = ref('')
|
const printingInboundId = ref('')
|
||||||
|
const relationshipDialogOpen = ref(false)
|
||||||
|
const relationshipDialogInboundCode = ref('')
|
||||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
||||||
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
||||||
@@ -286,6 +289,7 @@ function openDetail(row: InboundOrderSummary) {
|
|||||||
}
|
}
|
||||||
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
|
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
|
||||||
}
|
}
|
||||||
|
function openRelationship(row: InboundOrderSummary) { const code = row.inboundCode?.trim(); if (!code) return; relationshipDialogInboundCode.value = code; relationshipDialogOpen.value = true }
|
||||||
async function printCartonLabels(row: InboundOrderSummary) {
|
async function printCartonLabels(row: InboundOrderSummary) {
|
||||||
if (printingInboundId.value) return
|
if (printingInboundId.value) return
|
||||||
printingInboundId.value = row.id
|
printingInboundId.value = row.id
|
||||||
@@ -294,7 +298,7 @@ async function printCartonLabels(row: InboundOrderSummary) {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = url
|
anchor.href = url
|
||||||
anchor.download = `${row.inboundCode || row.id}-${row.totalPackageCount || '整单'}件-箱唛.pdf`
|
anchor.download = `${row.inboundCode || '入仓码待补充'}-${row.totalPackageCount || '整单'}件-箱唛.pdf`
|
||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
ElMessage.success('箱唛已生成,重复打印将继续使用原箱号')
|
ElMessage.success('箱唛已生成,重复打印将继续使用原箱号')
|
||||||
@@ -413,7 +417,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
|
|||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||||
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
||||||
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
|
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
|
||||||
<template #inbound="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
|
<template #inbound="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template>
|
||||||
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
|
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
|
||||||
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
|
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
|
||||||
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
||||||
@@ -456,12 +460,13 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.inbound-orders-page { min-width: 0; }
|
.inbound-orders-page { min-width: 0; }
|
||||||
|
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
|
||||||
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
|
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
|
||||||
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
|
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
|
||||||
.inbound-tabs::-webkit-scrollbar { display: none; }
|
.inbound-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|||||||
@@ -246,9 +246,9 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
|||||||
:customer-id="customerId"
|
:customer-id="customerId"
|
||||||
:inbound-id="inboundId"
|
:inbound-id="inboundId"
|
||||||
:file-id="row.intelligentImageFileId"
|
:file-id="row.intelligentImageFileId"
|
||||||
:alt="`箱规 ${row.boxCode || row.itemNo || row.id} 的测量图片`"
|
:alt="`箱规 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的测量图片`"
|
||||||
empty-text="无图片"
|
empty-text="无图片"
|
||||||
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || row.id)"
|
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || '箱规编码待补充')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ export interface MeasurementReviewDetail {
|
|||||||
measurementId: string
|
measurementId: string
|
||||||
sourceType: string | null
|
sourceType: string | null
|
||||||
measurementStatus: string | null
|
measurementStatus: string | null
|
||||||
|
/** 入库单乐观锁版本;审核接口 expectedVersion 使用此字段。 */
|
||||||
inboundVersion: number
|
inboundVersion: number
|
||||||
|
/** 测量批次自身版本,不用于入库单状态审核。 */
|
||||||
version: number
|
version: number
|
||||||
originalDocumentNo: string | null
|
originalDocumentNo: string | null
|
||||||
originalGrossWeightKg: number | null
|
originalGrossWeightKg: number | null
|
||||||
|
|||||||
@@ -82,17 +82,40 @@ describe('inbound weight OCR API', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads same-warehouse pallet snapshots through Firstleg', async () => {
|
it('normalizes the formal paged pallet response returned by Firstleg', async () => {
|
||||||
const options = [{ palletId: '11', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }]
|
const records = [
|
||||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: options })
|
{ palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 },
|
||||||
|
{ palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 },
|
||||||
|
]
|
||||||
|
vi.mocked(http.get).mockResolvedValue({
|
||||||
|
code: '200',
|
||||||
|
message: 'success',
|
||||||
|
data: { records, page: '1', size: '20', total: '2', pages: '1' },
|
||||||
|
})
|
||||||
|
|
||||||
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual(options)
|
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual({
|
||||||
|
records, page: 1, size: 20, total: 2, pages: 1,
|
||||||
|
})
|
||||||
expect(http.get).toHaveBeenCalledWith(
|
expect(http.get).toHaveBeenCalledWith(
|
||||||
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
|
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
|
||||||
{ params: { tenantId: '10001' } },
|
{ params: { tenantId: '10001' } },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => {
|
||||||
|
vi.mocked(http.get).mockResolvedValue({
|
||||||
|
code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' },
|
||||||
|
})
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
await getWeightPalletOptions('10001', '20001', controller.signal)
|
||||||
|
|
||||||
|
expect(http.get).toHaveBeenCalledWith(
|
||||||
|
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
|
||||||
|
{ params: { tenantId: '10001' }, signal: controller.signal },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
|
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
|
||||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
|
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
|
||||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
|
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CreateWeightResultRequest,
|
CreateWeightResultRequest,
|
||||||
WeightImageUploadResult,
|
WeightImageUploadResult,
|
||||||
WeightPalletOption,
|
WeightPalletOption,
|
||||||
|
WeightPalletOptionPage,
|
||||||
WeightResultResource,
|
WeightResultResource,
|
||||||
WeightWorkspaceResource,
|
WeightWorkspaceResource,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
@@ -15,10 +16,23 @@ const imageBasePath = '/platform/v1/files/measurements'
|
|||||||
const maxErrorEnvelopeBytes = 1024 * 1024
|
const maxErrorEnvelopeBytes = 1024 * 1024
|
||||||
const confidenceScale = 100_000
|
const confidenceScale = 100_000
|
||||||
|
|
||||||
|
interface WeightPalletOptionPageResponse {
|
||||||
|
records: WeightPalletOption[]
|
||||||
|
page: number | string
|
||||||
|
size: number | string
|
||||||
|
total: number | string
|
||||||
|
pages: number | string
|
||||||
|
}
|
||||||
|
|
||||||
function segment(value: string) {
|
function segment(value: string) {
|
||||||
return encodeURIComponent(value)
|
return encodeURIComponent(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedPageNumber(value: number | string, fallback: number, minimum: number) {
|
||||||
|
const parsed = typeof value === 'number' ? value : Number(value)
|
||||||
|
return Number.isFinite(parsed) ? Math.max(minimum, Math.trunc(parsed)) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeWeightConfidence(value: number | null) {
|
export function normalizeWeightConfidence(value: number | null) {
|
||||||
if (value == null || !Number.isFinite(value)) return null
|
if (value == null || !Number.isFinite(value)) return null
|
||||||
const clamped = Math.min(1, Math.max(0, value))
|
const clamped = Math.min(1, Math.max(0, value))
|
||||||
@@ -60,12 +74,19 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) {
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWeightPalletOptions(tenantId: string, inboundId: string) {
|
export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) {
|
||||||
const response = await http.get<ApiEnvelope<WeightPalletOption[]>, ApiEnvelope<WeightPalletOption[]>>(
|
const response = await http.get<ApiEnvelope<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>(
|
||||||
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
|
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
|
||||||
{ params: { tenantId } },
|
{ params: { tenantId }, ...(signal ? { signal } : {}) },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
const page = unwrapApiResponse(response)
|
||||||
|
return {
|
||||||
|
records: page.records,
|
||||||
|
page: normalizedPageNumber(page.page, 1, 1),
|
||||||
|
size: normalizedPageNumber(page.size, page.records.length, 0),
|
||||||
|
total: normalizedPageNumber(page.total, page.records.length, 0),
|
||||||
|
pages: normalizedPageNumber(page.pages, 0, 0),
|
||||||
|
} satisfies WeightPalletOptionPage
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
|
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ const workspace: WeightWorkspaceResource = {
|
|||||||
weightResults: [pendingWeight],
|
weightResults: [pendingWeight],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function palletPage(records = [{
|
||||||
|
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
|
||||||
|
}]) {
|
||||||
|
return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
enableAutoUnmount(afterEach)
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
const allWeightPermissions = [
|
const allWeightPermissions = [
|
||||||
@@ -122,9 +128,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
|
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
|
||||||
vi.mocked(getWeightPalletOptions).mockResolvedValue([{
|
vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage())
|
||||||
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
|
|
||||||
}])
|
|
||||||
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
|
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
|
||||||
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
||||||
vi.stubGlobal('URL', {
|
vi.stubGlobal('URL', {
|
||||||
@@ -326,7 +330,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows an actionable empty state when the warehouse has no pallet options', async () => {
|
it('shows an actionable empty state when the warehouse has no pallet options', async () => {
|
||||||
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce([])
|
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce(palletPage([]))
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -334,6 +338,28 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true)
|
expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders records from the formal paged response without showing a load failure', async () => {
|
||||||
|
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce({
|
||||||
|
records: [
|
||||||
|
{ palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 },
|
||||||
|
{ palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 },
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
total: 2,
|
||||||
|
pages: 1,
|
||||||
|
})
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.pallet-picker .el-select').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³')
|
||||||
|
expect(document.body.textContent).toContain('塑料托盘 · 皮重 10.000 KG · 容量 1.1 m³')
|
||||||
|
expect(wrapper.text()).not.toContain('可选托盘加载失败')
|
||||||
|
})
|
||||||
|
|
||||||
it('shows pallet loading failures without hiding existing weight results', async () => {
|
it('shows pallet loading failures without hiding existing weight results', async () => {
|
||||||
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
|
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
@@ -352,6 +378,64 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps historical pallet snapshots visible without requesting candidates for a read-only operator', async () => {
|
||||||
|
const wrapper = mountPanel(['platform:firstleg:inbound:measure:view'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(getWeightWorkspace).toHaveBeenCalledOnce()
|
||||||
|
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toContain('木托盘')
|
||||||
|
expect(wrapper.text()).toContain('12.000 KG')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not request pallet candidates when the inbound status is not editable', async () => {
|
||||||
|
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, processStatus: 'MEASURE_PENDING' })
|
||||||
|
|
||||||
|
mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('virtualizes one thousand pallet candidates instead of rendering one thousand option nodes', async () => {
|
||||||
|
vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage(Array.from({ length: 1_000 }, (_, index) => ({
|
||||||
|
palletId: String(60_000 + index),
|
||||||
|
palletCode: `TP-${index}`,
|
||||||
|
palletType: `托盘类型 ${index}`,
|
||||||
|
capacityCbm: 1.2,
|
||||||
|
tareWeightKg: 12,
|
||||||
|
}))))
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.pallet-picker .el-select').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const renderedOptions = document.body.querySelectorAll('.el-select-dropdown__item')
|
||||||
|
expect(renderedOptions.length).toBeGreaterThan(0)
|
||||||
|
expect(renderedOptions.length).toBeLessThan(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts an in-flight pallet request and ignores its late result when the panel closes', async () => {
|
||||||
|
let resolveOptions: ((value: Awaited<ReturnType<typeof getWeightPalletOptions>>) => void) | undefined
|
||||||
|
vi.mocked(getWeightPalletOptions).mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveOptions = resolve
|
||||||
|
}))
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
resolveOptions?.(palletPage([{
|
||||||
|
palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10,
|
||||||
|
}]))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(signal?.aborted).toBe(true)
|
||||||
|
expect(document.body.textContent).not.toContain('过期托盘')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a low-confidence OCR result focused on the editable weight', async () => {
|
it('keeps a low-confidence OCR result focused on the editable weight', async () => {
|
||||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||||
...workspace,
|
...workspace,
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain("option.palletType || '未记录类型'")
|
expect(source).toContain("option.palletType || '未记录类型'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps pallet type, tare weight, and capacity in one select line', () => {
|
it('keeps pallet type, tare weight, and capacity in one virtualized select line', () => {
|
||||||
expect(source).toContain(':label="palletOptionLabel(option)"')
|
expect(source).toContain('<el-select-v2')
|
||||||
|
expect(source).toContain(':options="virtualPalletOptions"')
|
||||||
|
expect(source).toContain('label: palletOptionLabel(option)')
|
||||||
expect(source).toContain("`${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`")
|
expect(source).toContain("`${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`")
|
||||||
expect(source).not.toContain('<small v-if="selectedPallet">')
|
expect(source).not.toContain('<small v-if="selectedPallet">')
|
||||||
expect(source).toContain('width: clamp(360px, 36vw, 520px)')
|
expect(source).toContain('width: clamp(360px, 36vw, 520px)')
|
||||||
@@ -67,6 +69,9 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain("platform:firstleg:inbound:weight:edit")
|
expect(source).toContain("platform:firstleg:inbound:weight:edit")
|
||||||
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
|
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
|
||||||
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
|
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
|
||||||
|
expect(source).toContain('&& canEdit.value')
|
||||||
|
expect(source).toContain('&& editable.value')
|
||||||
|
expect(source).toContain('cancelPalletOptionsLoad()')
|
||||||
expect(source).toContain("workspace.value?.processStatus === 'MEASURE_PENDING'")
|
expect(source).toContain("workspace.value?.processStatus === 'MEASURE_PENDING'")
|
||||||
expect(source).toContain('v-if="canEdit && !reviewMode"')
|
expect(source).toContain('v-if="canEdit && !reviewMode"')
|
||||||
expect(source).toContain('v-if="canEdit && editable && !reviewMode"')
|
expect(source).toContain('v-if="canEdit && editable && !reviewMode"')
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const previewResultId = ref('')
|
|||||||
const previewSnapshot = ref<WeightResultResource | null>(null)
|
const previewSnapshot = ref<WeightResultResource | null>(null)
|
||||||
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB'; reason: string }>>({})
|
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB'; reason: string }>>({})
|
||||||
let loadSequence = 0
|
let loadSequence = 0
|
||||||
|
let palletLoadSequence = 0
|
||||||
|
let palletAbortController: AbortController | null = null
|
||||||
|
|
||||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||||
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
|
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
|
||||||
@@ -68,6 +70,18 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit
|
|||||||
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
|
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
|
||||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||||
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
||||||
|
const canLoadPalletOptions = computed(() => Boolean(
|
||||||
|
!props.reviewMode
|
||||||
|
&& customerId.value
|
||||||
|
&& props.inboundId
|
||||||
|
&& canView.value
|
||||||
|
&& canEdit.value
|
||||||
|
&& editable.value,
|
||||||
|
))
|
||||||
|
const virtualPalletOptions = computed(() => palletOptions.value.map((option) => ({
|
||||||
|
label: palletOptionLabel(option),
|
||||||
|
value: option.palletId,
|
||||||
|
})))
|
||||||
const reviewCorrectionEnabled = computed(() => Boolean(
|
const reviewCorrectionEnabled = computed(() => Boolean(
|
||||||
props.reviewMode
|
props.reviewMode
|
||||||
&& props.correctionMode
|
&& props.correctionMode
|
||||||
@@ -212,32 +226,44 @@ async function loadWorkspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPalletOptions() {
|
async function loadPalletOptions() {
|
||||||
if (props.reviewMode) {
|
if (!canLoadPalletOptions.value) {
|
||||||
|
cancelPalletOptionsLoad()
|
||||||
palletOptions.value = []
|
palletOptions.value = []
|
||||||
selectedPalletId.value = ''
|
selectedPalletId.value = ''
|
||||||
palletError.value = null
|
palletError.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
cancelPalletOptionsLoad()
|
||||||
palletOptions.value = []
|
const sequence = ++palletLoadSequence
|
||||||
selectedPalletId.value = ''
|
const controller = new AbortController()
|
||||||
return
|
palletAbortController = controller
|
||||||
}
|
|
||||||
palletLoading.value = true
|
palletLoading.value = true
|
||||||
palletError.value = null
|
palletError.value = null
|
||||||
try {
|
try {
|
||||||
const result = await getWeightPalletOptions(customerId.value, props.inboundId)
|
const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal)
|
||||||
palletOptions.value = result
|
if (sequence !== palletLoadSequence || controller.signal.aborted) return
|
||||||
if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
|
palletOptions.value = result.records
|
||||||
|
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (sequence !== palletLoadSequence || controller.signal.aborted) return
|
||||||
palletOptions.value = []
|
palletOptions.value = []
|
||||||
selectedPalletId.value = ''
|
selectedPalletId.value = ''
|
||||||
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
|
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
palletLoading.value = false
|
if (sequence === palletLoadSequence) {
|
||||||
|
palletLoading.value = false
|
||||||
|
if (palletAbortController === controller) palletAbortController = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelPalletOptionsLoad() {
|
||||||
|
++palletLoadSequence
|
||||||
|
palletAbortController?.abort()
|
||||||
|
palletAbortController = null
|
||||||
|
palletLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function chooseFile() {
|
function chooseFile() {
|
||||||
if (!editable.value) {
|
if (!editable.value) {
|
||||||
ElMessage.warning('当前入库状态不允许上传称重图片')
|
ElMessage.warning('当前入库状态不允许上传称重图片')
|
||||||
@@ -397,17 +423,32 @@ watch(
|
|||||||
previewOpen.value = false
|
previewOpen.value = false
|
||||||
releasePreview()
|
releasePreview()
|
||||||
void loadWorkspace()
|
void loadWorkspace()
|
||||||
void loadPalletOptions()
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [customerId.value, props.inboundId, canLoadPalletOptions.value] as const,
|
||||||
|
() => {
|
||||||
|
cancelPalletOptionsLoad()
|
||||||
|
palletOptions.value = []
|
||||||
|
selectedPalletId.value = ''
|
||||||
|
palletError.value = null
|
||||||
|
if (canLoadPalletOptions.value) void loadPalletOptions()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(previewOpen, (open) => {
|
watch(previewOpen, (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
releasePreview()
|
releasePreview()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onBeforeUnmount(releasePreview)
|
onBeforeUnmount(() => {
|
||||||
|
cancelPalletOptionsLoad()
|
||||||
|
palletOptions.value = []
|
||||||
|
selectedPalletId.value = ''
|
||||||
|
releasePreview()
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ reload: loadWorkspace })
|
defineExpose({ reload: loadWorkspace })
|
||||||
</script>
|
</script>
|
||||||
@@ -424,22 +465,16 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
|
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pallet-picker">
|
<div class="pallet-picker">
|
||||||
<el-select
|
<el-select-v2
|
||||||
v-if="!reviewMode"
|
v-if="canEdit && !reviewMode"
|
||||||
v-model="selectedPalletId"
|
v-model="selectedPalletId"
|
||||||
|
:options="virtualPalletOptions"
|
||||||
aria-label="选择称重托盘"
|
aria-label="选择称重托盘"
|
||||||
placeholder="选择称重托盘"
|
placeholder="选择称重托盘"
|
||||||
filterable
|
filterable
|
||||||
:loading="palletLoading"
|
:loading="palletLoading"
|
||||||
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
|
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
|
||||||
>
|
/>
|
||||||
<el-option
|
|
||||||
v-for="option in palletOptions"
|
|
||||||
:key="option.palletId"
|
|
||||||
:label="palletOptionLabel(option)"
|
|
||||||
:value="option.palletId"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-if="!reviewMode"
|
v-if="!reviewMode"
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export interface WeightPalletOption {
|
|||||||
tareWeightKg: number
|
tareWeightKg: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WeightPalletOptionPage {
|
||||||
|
records: WeightPalletOption[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeightImageUploadResult {
|
export interface WeightImageUploadResult {
|
||||||
fileId: string
|
fileId: string
|
||||||
fileName: string
|
fileName: string
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { http } from '@/shared/api/http'
|
|||||||
import {
|
import {
|
||||||
getConfirmedLoadingList,
|
getConfirmedLoadingList,
|
||||||
confirmPreloadPlan,
|
confirmPreloadPlan,
|
||||||
uploadLoadingDeclaration,
|
|
||||||
completeLoadingDeclaration,
|
|
||||||
downloadLoadingDeclaration,
|
|
||||||
getPreloadPlan,
|
getPreloadPlan,
|
||||||
listConfirmedLoadingLists,
|
listConfirmedLoadingLists,
|
||||||
listPreloadPlans,
|
listPreloadPlans,
|
||||||
@@ -162,28 +159,4 @@ describe('platform loading lists API', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses stable plan and warehouse ids for customs document actions', async () => {
|
|
||||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
|
|
||||||
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
|
|
||||||
await uploadLoadingDeclaration('100', '300', file, 1, '上传资料')
|
|
||||||
await completeLoadingDeclaration('100', '300', 2, '完成报关')
|
|
||||||
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/document/upload')
|
|
||||||
const body = post.mock.calls[0]?.[1] as FormData
|
|
||||||
expect(body.get('file')).toBe(file)
|
|
||||||
expect(body.get('expectedVersion')).toBe('1')
|
|
||||||
expect(body.get('operationReason')).toBe('上传资料')
|
|
||||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
|
||||||
expect(post.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/complete')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('downloads the current customs attachment through the exact loading-list endpoint with string ids', async () => {
|
|
||||||
const blob = new Blob(['declaration'], { type: 'application/pdf' })
|
|
||||||
const get = vi.spyOn(http, 'get').mockResolvedValue(blob)
|
|
||||||
const result = await downloadLoadingDeclaration('2092438843630391401', '193000000000200002', '2092438843630391502')
|
|
||||||
expect(get).toHaveBeenCalledWith(
|
|
||||||
'/platform/v1/first-leg/loading-lists/2092438843630391401/customs/document/2092438843630391502/download',
|
|
||||||
{ params: { warehouseId: '193000000000200002' }, responseType: 'blob' },
|
|
||||||
)
|
|
||||||
expect(result).toBe(blob)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -126,26 +126,10 @@ export interface LoadingHistory {
|
|||||||
occurredAt: string
|
occurredAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessDocument {
|
|
||||||
id: string | null
|
|
||||||
documentType: string
|
|
||||||
fileId: string
|
|
||||||
fileName: string
|
|
||||||
fileHash: string | null
|
|
||||||
uploadedBy: string | null
|
|
||||||
uploadedByName: string | null
|
|
||||||
uploadedAt: string | null
|
|
||||||
completedBy: string | null
|
|
||||||
completedByName: string | null
|
|
||||||
completedAt: string | null
|
|
||||||
version: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoadingDetail<T extends LoadingListSummary = LoadingListSummary> {
|
export interface LoadingDetail<T extends LoadingListSummary = LoadingListSummary> {
|
||||||
summary: T
|
summary: T
|
||||||
items: LoadingItem[]
|
items: LoadingItem[]
|
||||||
histories: LoadingHistory[]
|
histories: LoadingHistory[]
|
||||||
customsDocument?: BusinessDocument | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadingListPage<T extends LoadingListSummary = LoadingListSummary> {
|
export interface LoadingListPage<T extends LoadingListSummary = LoadingListSummary> {
|
||||||
@@ -271,11 +255,6 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
|
||||||
if (!raw) return null
|
|
||||||
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
|
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
|
||||||
return {
|
return {
|
||||||
records: (raw.records || []).map(normalize),
|
records: (raw.records || []).map(normalize),
|
||||||
@@ -308,7 +287,6 @@ export async function getPreloadPlan(planId: string, warehouseId: string) {
|
|||||||
summary: normalizePreloadSummary(result.summary),
|
summary: normalizePreloadSummary(result.summary),
|
||||||
items: (result.items || []).map(normalizeItem),
|
items: (result.items || []).map(normalizeItem),
|
||||||
histories: (result.histories || []).map(normalizeHistory),
|
histories: (result.histories || []).map(normalizeHistory),
|
||||||
customsDocument: normalizeDocument(result.customsDocument),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +301,6 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
|
|||||||
summary: normalizePreloadSummary(result.summary),
|
summary: normalizePreloadSummary(result.summary),
|
||||||
items: (result.items || []).map(normalizeItem),
|
items: (result.items || []).map(normalizeItem),
|
||||||
histories: (result.histories || []).map(normalizeHistory),
|
histories: (result.histories || []).map(normalizeHistory),
|
||||||
customsDocument: normalizeDocument(result.customsDocument),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,32 +332,6 @@ export async function confirmPreloadPlan(planId: string, expectedVersion: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
|
|
||||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
|
|
||||||
return normalizeDocument(unwrapApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadLoadingDeclaration(planId: string, warehouseId: string, file: File, expectedVersion: number, operationReason?: string | null) {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('file', file)
|
|
||||||
body.append('expectedVersion', String(expectedVersion))
|
|
||||||
if (operationReason) body.append('operationReason', operationReason)
|
|
||||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/upload`, body, { params: { warehouseId }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': `loading-customs-upload-${planId}-${Date.now()}` } })
|
|
||||||
return normalizeDocument(unwrapApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadLoadingDeclaration(planId: string, warehouseId: string, fileId: string) {
|
|
||||||
return http.get<Blob, Blob>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/${encodeURIComponent(fileId)}/download`, {
|
|
||||||
params: { warehouseId },
|
|
||||||
responseType: 'blob',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function completeLoadingDeclaration(planId: string, warehouseId: string, expectedVersion: number, operationReason?: string | null) {
|
|
||||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/complete`, { expectedVersion, operationReason }, { params: { warehouseId }, headers: { 'Idempotency-Key': `loading-customs-complete-${planId}-${Date.now()}` } })
|
|
||||||
return normalizeDocument(unwrapApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listConfirmedLoadingLists(query: LoadingListQuery) {
|
export async function listConfirmedLoadingLists(query: LoadingListQuery) {
|
||||||
const response = await http.get<ApiEnvelope<RawPage<ConfirmedSummary>>, ApiEnvelope<RawPage<ConfirmedSummary>>>(
|
const response = await http.get<ApiEnvelope<RawPage<ConfirmedSummary>>, ApiEnvelope<RawPage<ConfirmedSummary>>>(
|
||||||
`${basePath}/loading-lists`,
|
`${basePath}/loading-lists`,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
|
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
|
||||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ vi.mock('../api/containerFees', async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
||||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||||
})
|
})
|
||||||
|
vi.mock('@/modules/finance', () => ({
|
||||||
|
getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||||
@@ -23,7 +27,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
|
|||||||
const pinia = createPinia(); setActivePinia(pinia)
|
const pinia = createPinia(); setActivePinia(pinia)
|
||||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||||
useAccessPreviewStore().initializeAfterLogin()
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers }, global: { plugins: [pinia, ElementPlus] } })
|
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers, businessReferences: [{ orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
|
||||||
await flushPromises(); return wrapper
|
await flushPromises(); return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +37,9 @@ beforeEach(() => {
|
|||||||
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
|
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
|
||||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||||
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
||||||
|
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue({} as never)
|
||||||
|
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
|
||||||
|
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
|
||||||
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
|
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
@@ -82,6 +89,10 @@ describe('ContainerFeesPanel contract', () => {
|
|||||||
expect(panel).toContain("cost.supplierName || '服务端未返回'")
|
expect(panel).toContain("cost.supplierName || '服务端未返回'")
|
||||||
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
|
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
|
||||||
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
|
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
|
||||||
|
expect(panel).toContain("orderNumbers.get(row.orderId) || '订单编码待补充'")
|
||||||
|
expect(panel).toContain("inboundCodes.get(row.inboundId) || '入仓码待补充'")
|
||||||
|
expect(panel).not.toContain("prop: 'orderId', label: '订单'")
|
||||||
|
expect(panel).not.toContain("prop: 'inboundId', label: '入库单'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears the previous customer context and ignores a late response', async () => {
|
it('clears the previous customer context and ignores a late response', async () => {
|
||||||
@@ -140,6 +151,30 @@ describe('ContainerFeesPanel contract', () => {
|
|||||||
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides adjustment and void actions for settled fees', async () => {
|
||||||
|
vi.mocked(listContainerFees).mockResolvedValue([
|
||||||
|
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||||
|
])
|
||||||
|
vi.mocked(listFinanceReceivableSettlements).mockResolvedValue([{ id: 'ar-1', status: 'PAID', paymentStatus: 'PAID' } as never])
|
||||||
|
vi.mocked(getFinanceSettlement).mockResolvedValue({ lines: [{ sourceType: 'CONTAINER_FEE', sourceFeeId: '1' }] } as never)
|
||||||
|
const wrapper = await mountPanel([
|
||||||
|
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
|
||||||
|
'platform:finance:settlement-ar:list',
|
||||||
|
])
|
||||||
|
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
|
||||||
|
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||||
|
expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides adjustment and void actions for directly paid payable fees', async () => {
|
||||||
|
vi.mocked(listContainerFees).mockResolvedValue([
|
||||||
|
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||||
|
])
|
||||||
|
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review'])
|
||||||
|
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
|
||||||
|
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps receivable usable when supplier permission is missing', async () => {
|
it('keeps receivable usable when supplier permission is missing', async () => {
|
||||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
|
||||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||||
|
|
||||||
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
const props = defineProps<{
|
||||||
|
planId: string
|
||||||
|
containerBookingId: string
|
||||||
|
customers: Array<{ accountId: string; customerName: string | null }>
|
||||||
|
businessReferences: Array<{ orderId: string; orderNo: string; inboundId: string; inboundCode: string | null }>
|
||||||
|
}>()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const accountId = ref('')
|
const accountId = ref('')
|
||||||
const fees = ref<ContainerFeeResource[]>([])
|
const fees = ref<ContainerFeeResource[]>([])
|
||||||
|
const settledFeeIds = ref<Set<string>>(new Set())
|
||||||
const feeItems = ref<ApplicableFeeItem[]>([])
|
const feeItems = ref<ApplicableFeeItem[]>([])
|
||||||
const suppliers = ref<ContainerFeeSupplierOption[]>([])
|
const suppliers = ref<ContainerFeeSupplierOption[]>([])
|
||||||
const costs = ref<ContainerCostDetail[]>([])
|
const costs = ref<ContainerCostDetail[]>([])
|
||||||
@@ -54,6 +61,8 @@ const customerOptions = computed(() => {
|
|||||||
return [...unique].map(([value, label]) => ({ value, label }))
|
return [...unique].map(([value, label]) => ({ value, label }))
|
||||||
})
|
})
|
||||||
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
|
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
|
||||||
|
const orderNumbers = computed(() => new Map(props.businessReferences.map((item) => [item.orderId, item.orderNo])))
|
||||||
|
const inboundCodes = computed(() => new Map(props.businessReferences.map((item) => [item.inboundId, item.inboundCode])))
|
||||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
|
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
|
||||||
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
|
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
|
||||||
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
|
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
|
||||||
@@ -71,8 +80,8 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
|
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
|
||||||
]
|
]
|
||||||
const allocationColumns: DataTableColumn[] = [
|
const allocationColumns: DataTableColumn[] = [
|
||||||
{ key: 'orderId', prop: 'orderId', label: '订单', fixed: 'left', minWidth: 150 },
|
{ key: 'orderNo', label: '订单编码', fixed: 'left', minWidth: 170, slotName: 'orderNo' },
|
||||||
{ key: 'inboundId', prop: 'inboundId', label: '入库单', minWidth: 150 },
|
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
|
||||||
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
|
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
|
||||||
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
|
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
|
||||||
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
|
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
|
||||||
@@ -98,6 +107,9 @@ function paidSummary(item: ContainerFeeResource) {
|
|||||||
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
|
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
|
||||||
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
|
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
|
||||||
}
|
}
|
||||||
|
function isSettled(item: ContainerFeeResource) {
|
||||||
|
return item.settlementMode === 'DIRECT_PAID' || settledFeeIds.value.has(item.id)
|
||||||
|
}
|
||||||
async function selectVoucher(event: Event) {
|
async function selectVoucher(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
@@ -153,6 +165,8 @@ async function load() {
|
|||||||
const feeRows = await listContainerFees(props.planId, requestedAccountId)
|
const feeRows = await listContainerFees(props.planId, requestedAccountId)
|
||||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||||
fees.value = feeRows
|
fees.value = feeRows
|
||||||
|
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
|
||||||
|
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||||
costs.value = []
|
costs.value = []
|
||||||
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
|
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
|
||||||
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
||||||
@@ -166,6 +180,28 @@ async function load() {
|
|||||||
finally { if (sequence === loadSequence) loading.value = false }
|
finally { if (sequence === loadSequence) loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) {
|
||||||
|
const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
|
||||||
|
settledFeeIds.value = settled
|
||||||
|
const listRequests: Array<Promise<Awaited<ReturnType<typeof listFinanceReceivableSettlements>> | Awaited<ReturnType<typeof listFinancePayableSettlements>>>> = []
|
||||||
|
if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId))
|
||||||
|
if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId))
|
||||||
|
if (!listRequests.length) return
|
||||||
|
|
||||||
|
const settlementResults = await Promise.allSettled(listRequests)
|
||||||
|
const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled'
|
||||||
|
? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID')
|
||||||
|
: [])
|
||||||
|
const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id)))
|
||||||
|
for (const result of detailResults) {
|
||||||
|
if (result.status !== 'fulfilled') continue
|
||||||
|
for (const line of result.value.lines) {
|
||||||
|
if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
|
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
|
||||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
|
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -230,7 +266,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length =
|
|||||||
function clearCustomerContext() {
|
function clearCustomerContext() {
|
||||||
loadSequence += 1
|
loadSequence += 1
|
||||||
supplierLoadSequence += 1
|
supplierLoadSequence += 1
|
||||||
fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
||||||
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
@@ -263,7 +299,7 @@ onMounted(() => void load())
|
|||||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||||
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||||
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
||||||
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
||||||
@@ -271,7 +307,7 @@ onMounted(() => void load())
|
|||||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
|
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ArrowLeft, Check, FileUp, Pencil } from '@lucide/vue'
|
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
@@ -17,12 +17,12 @@ const props = defineProps<{
|
|||||||
detail: LoadingDetail | null
|
detail: LoadingDetail | null
|
||||||
error: ApiErrorDetails | null
|
error: ApiErrorDetails | null
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
|
canEditTransport?: boolean
|
||||||
|
transportNote?: string
|
||||||
confirmSaving?: boolean
|
confirmSaving?: boolean
|
||||||
canManageCustoms?: boolean
|
|
||||||
customsSaving?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
|
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: [] }>()
|
||||||
|
|
||||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
|
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
|
||||||
const baseItemColumns: DataTableColumn[] = [
|
const baseItemColumns: DataTableColumn[] = [
|
||||||
@@ -49,7 +49,6 @@ const baseItemColumns: DataTableColumn[] = [
|
|||||||
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
|
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
|
||||||
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
|
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
|
||||||
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
|
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
|
||||||
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
|
|
||||||
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
|
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -84,6 +83,12 @@ const feeCustomers = computed(() => {
|
|||||||
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||||
})
|
})
|
||||||
|
const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({
|
||||||
|
orderId: item.orderId,
|
||||||
|
orderNo: item.orderNo,
|
||||||
|
inboundId: item.inboundId,
|
||||||
|
inboundCode: item.inboundCode,
|
||||||
|
})))
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
|
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
|
||||||
const number = props.mode === 'preload'
|
const number = props.mode === 'preload'
|
||||||
@@ -118,18 +123,13 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
<div class="detail-page-header__identity">
|
<div class="detail-page-header__identity">
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细、报关资料和柜次费用' }}</p>
|
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||||
<template v-if="canEditItemRemark">
|
<template v-if="canEditItemRemark">
|
||||||
<el-button @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />纠正集装箱号 / 航次</el-button>
|
||||||
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="mode === 'confirmed'">
|
|
||||||
<el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button>
|
|
||||||
<el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button>
|
|
||||||
<StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" />
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
|
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
|
||||||
@@ -158,6 +158,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
<strong>暂不能确认预装:</strong>
|
<strong>暂不能确认预装:</strong>
|
||||||
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
|
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="preloadSummary" class="readiness-note transport-note" role="status"><strong>真实集装箱号:</strong><span>{{ transportNote }}</span></div>
|
||||||
|
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -181,11 +182,10 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
|
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
|
||||||
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
|
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
|
||||||
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
||||||
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
|
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
|
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||||
|
|
||||||
<section class="history-section" aria-label="操作历史">
|
<section class="history-section" aria-label="操作历史">
|
||||||
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
|
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('LoadingListsView', () => {
|
|||||||
expect(source).toContain('LoadErrorState')
|
expect(source).toContain('LoadErrorState')
|
||||||
expect(source).toContain('ApiErrorMeta')
|
expect(source).toContain('ApiErrorMeta')
|
||||||
expect(preloadDetailSource).toContain('AppDialog')
|
expect(preloadDetailSource).toContain('AppDialog')
|
||||||
expect(confirmedDetailSource).toContain('AppDialog')
|
expect(confirmedDetailSource).not.toContain('AppDialog')
|
||||||
expect(detailSource).not.toContain('AppDrawer')
|
expect(detailSource).not.toContain('AppDrawer')
|
||||||
expect(detailSource).toContain('ContainerFeesPanel')
|
expect(detailSource).toContain('ContainerFeesPanel')
|
||||||
})
|
})
|
||||||
@@ -81,8 +81,8 @@ describe('LoadingListsView', () => {
|
|||||||
it('only exposes transport editing for LOADING preload details', () => {
|
it('only exposes transport editing for LOADING preload details', () => {
|
||||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||||
expect(detailSource).toContain('const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)')
|
expect(detailSource).toContain('const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)')
|
||||||
expect(detailSource).toContain('报关资料')
|
expect(detailSource).not.toContain('报关资料')
|
||||||
expect(detailSource).toContain('完成报关')
|
expect(detailSource).not.toContain('完成报关')
|
||||||
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
|
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,8 +96,9 @@ describe('LoadingListsView', () => {
|
|||||||
it('keeps transport editing as a detail-page action', () => {
|
it('keeps transport editing as a detail-page action', () => {
|
||||||
expect(source).not.toContain('openEditFromRow(row)')
|
expect(source).not.toContain('openEditFromRow(row)')
|
||||||
expect(source).not.toContain('编辑运输信息')
|
expect(source).not.toContain('编辑运输信息')
|
||||||
expect(detailSource).toContain("{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}")
|
expect(detailSource).toContain('纠正集装箱号 / 航次')
|
||||||
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')")
|
expect(detailSource).toContain('v-if="canEditTransport"')
|
||||||
|
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes the detail version on A0409 while preserving transport input', () => {
|
it('refreshes the detail version on A0409 while preserving transport input', () => {
|
||||||
@@ -168,7 +169,6 @@ describe('LoadingListsView', () => {
|
|||||||
'customerRemark',
|
'customerRemark',
|
||||||
'internalRemark',
|
'internalRemark',
|
||||||
'warehouseRemark',
|
'warehouseRemark',
|
||||||
'customsDocument',
|
|
||||||
]
|
]
|
||||||
let previous = detailSource.indexOf("key: 'customerName'")
|
let previous = detailSource.indexOf("key: 'customerName'")
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
@@ -234,15 +234,10 @@ describe('LoadingListsView', () => {
|
|||||||
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
|
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uploads declaration documents from a file picker and freezes them after completion', () => {
|
it('does not retain the retired loading-list customs entry', () => {
|
||||||
expect(confirmedDetailSource).toContain('<el-upload :auto-upload="false" :limit="1"')
|
expect(confirmedDetailSource).not.toContain('LoadingDeclaration')
|
||||||
expect(confirmedDetailSource).toContain('@confirm="uploadCustomsDocument"')
|
expect(confirmedDetailSource).not.toContain('customsDocument')
|
||||||
expect(confirmedDetailSource).toContain('uploadLoadingDeclaration(')
|
expect(detailSource).not.toContain('customsDocument')
|
||||||
expect(confirmedDetailSource).toContain('getLoadingDeclaration(planId.value, warehouseId.value)')
|
expect(detailSource).not.toContain('报关资料')
|
||||||
expect(confirmedDetailSource).toContain("details.code === 'A0404'")
|
|
||||||
expect(detailSource).toContain("canManageCustoms && !detail.customsDocument?.completedAt")
|
|
||||||
expect(confirmedDetailSource).not.toContain('文件 ID')
|
|
||||||
expect(confirmedDetailSource).not.toContain('fileId')
|
|
||||||
expect(confirmedDetailSource).not.toContain('上传入口待')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './api/platformLoadingLists'
|
export * from './api/platformLoadingLists'
|
||||||
export { loadingListRoutes } from './routes'
|
export { loadingListRoutes } from './routes'
|
||||||
export { loadingListStatusLabel } from './status'
|
export { loadingListStatusLabel } from './status'
|
||||||
|
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { FileUp } from '@lucide/vue'
|
|
||||||
import { ElMessage, type UploadFile } from 'element-plus'
|
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
||||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||||
import {
|
import {
|
||||||
completeLoadingDeclaration,
|
|
||||||
getConfirmedLoadingList,
|
getConfirmedLoadingList,
|
||||||
getLoadingDeclaration,
|
|
||||||
uploadLoadingDeclaration,
|
|
||||||
type ConfirmedSummary,
|
type ConfirmedSummary,
|
||||||
type LoadingDetail,
|
type LoadingDetail,
|
||||||
} from '../api/platformLoadingLists'
|
} from '../api/platformLoadingLists'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const access = useAccessPreviewStore()
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
||||||
const error = ref<ApiErrorDetails | null>(null)
|
const error = ref<ApiErrorDetails | null>(null)
|
||||||
const customsDialogOpen = ref(false)
|
|
||||||
const customsFile = ref<File | null>(null)
|
|
||||||
const customsSaving = ref(false)
|
|
||||||
const customsError = ref<ApiErrorDetails | null>(null)
|
|
||||||
let loadSequence = 0
|
let loadSequence = 0
|
||||||
const planId = computed(() => String(route.params.planId || ''))
|
const planId = computed(() => String(route.params.planId || ''))
|
||||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
const sequence = ++loadSequence
|
const sequence = ++loadSequence
|
||||||
@@ -44,20 +30,13 @@ async function loadDetail() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const [result, customsDocument] = await Promise.all([
|
const result = await getConfirmedLoadingList(planId.value, warehouseId.value)
|
||||||
getConfirmedLoadingList(planId.value, warehouseId.value),
|
|
||||||
getLoadingDeclaration(planId.value, warehouseId.value).catch((cause) => {
|
|
||||||
const details = getApiErrorDetails(cause)
|
|
||||||
if (details.code === 'A0404') return null
|
|
||||||
throw cause
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
if (sequence !== loadSequence) return
|
if (sequence !== loadSequence) return
|
||||||
if (result.summary.status !== 'COMPLETED') {
|
if (result.summary.status !== 'COMPLETED') {
|
||||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
|
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
detail.value = { ...result, customsDocument }
|
detail.value = result
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (sequence !== loadSequence) return
|
if (sequence !== loadSequence) return
|
||||||
detail.value = null
|
detail.value = null
|
||||||
@@ -72,66 +51,6 @@ function goBack() {
|
|||||||
else void router.push({ name: 'loading-lists' })
|
else void router.push({ name: 'loading-lists' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCustomsDocument() {
|
|
||||||
customsFile.value = null
|
|
||||||
customsError.value = null
|
|
||||||
customsDialogOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCustomsFile(uploadFile: UploadFile) {
|
|
||||||
customsFile.value = uploadFile.raw ?? null
|
|
||||||
customsError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCustomsFile() {
|
|
||||||
customsFile.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadCustomsDocument() {
|
|
||||||
if (!detail.value || !customsFile.value) {
|
|
||||||
customsError.value = { code: 'FILE_REQUIRED', message: '请选择报关资料文件' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
customsSaving.value = true
|
|
||||||
customsError.value = null
|
|
||||||
try {
|
|
||||||
detail.value.customsDocument = await uploadLoadingDeclaration(
|
|
||||||
planId.value,
|
|
||||||
warehouseId.value,
|
|
||||||
customsFile.value,
|
|
||||||
detail.value.customsDocument?.version || 0,
|
|
||||||
'上传装柜清单报关资料',
|
|
||||||
)
|
|
||||||
customsFile.value = null
|
|
||||||
customsDialogOpen.value = false
|
|
||||||
ElMessage.success('报关资料已上传')
|
|
||||||
} catch (cause) {
|
|
||||||
customsError.value = getApiErrorDetails(cause, '报关资料上传失败')
|
|
||||||
} finally {
|
|
||||||
customsSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function completeCustomsDocument() {
|
|
||||||
if (!detail.value?.customsDocument) return
|
|
||||||
customsSaving.value = true
|
|
||||||
customsError.value = null
|
|
||||||
try {
|
|
||||||
detail.value.customsDocument = await completeLoadingDeclaration(
|
|
||||||
planId.value,
|
|
||||||
warehouseId.value,
|
|
||||||
detail.value.customsDocument.version,
|
|
||||||
'确认装柜清单报关完成',
|
|
||||||
)
|
|
||||||
ElMessage.success('已完成报关')
|
|
||||||
} catch (cause) {
|
|
||||||
customsError.value = getApiErrorDetails(cause, '报关完成失败')
|
|
||||||
ElMessage.error(customsError.value.message)
|
|
||||||
} finally {
|
|
||||||
customsSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => void loadDetail())
|
onMounted(() => void loadDetail())
|
||||||
watch([planId, warehouseId], () => void loadDetail())
|
watch([planId, warehouseId], () => void loadDetail())
|
||||||
</script>
|
</script>
|
||||||
@@ -139,40 +58,16 @@ watch([planId, warehouseId], () => void loadDetail())
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<div class="confirmed-loading-detail-page">
|
<div class="confirmed-loading-detail-page">
|
||||||
<OperationErrorToast :details="customsError" />
|
|
||||||
<LoadingListDetailDrawer
|
<LoadingListDetailDrawer
|
||||||
mode="confirmed"
|
mode="confirmed"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:detail="detail"
|
:detail="detail"
|
||||||
:error="error"
|
:error="error"
|
||||||
:can-edit="false"
|
:can-edit="false"
|
||||||
:can-manage-customs="canManageCustoms"
|
|
||||||
:customs-saving="customsSaving"
|
|
||||||
@back="goBack"
|
@back="goBack"
|
||||||
@retry="loadDetail"
|
@retry="loadDetail"
|
||||||
@attach-customs="openCustomsDocument"
|
|
||||||
@complete-customs="completeCustomsDocument"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDialog
|
|
||||||
v-model="customsDialogOpen"
|
|
||||||
title="装柜清单报关资料"
|
|
||||||
width="min(520px, calc(100vw - 28px))"
|
|
||||||
confirm-text="上传资料"
|
|
||||||
:confirm-loading="customsSaving"
|
|
||||||
:confirm-disabled="!customsFile"
|
|
||||||
@confirm="uploadCustomsDocument"
|
|
||||||
>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="报关资料">
|
|
||||||
<el-upload :auto-upload="false" :limit="1" :on-change="selectCustomsFile" :on-remove="removeCustomsFile">
|
|
||||||
<el-button><FileUp :size="16" />选择文件</el-button>
|
|
||||||
<template #tip><div class="el-upload__tip">{{ detail?.customsDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的装柜清单报关资料。' }}</div></template>
|
|
||||||
</el-upload>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</AppDialog>
|
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user