Merge branch 'release/test' of http://git.buggoglobal.com/html/client-web into dev

This commit is contained in:
2026-09-01 10:02:48 +08:00
44 changed files with 1208 additions and 142 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
VITE_OVERSEAS_API_BASE_URL=/overseas-api VITE_OVERSEAS_API_BASE_URL=/overseas-api
OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300
API_PROXY_TARGET=http://8.163.30.128:9000 API_PROXY_TARGET=http://127.0.0.1:9000
VITE_ADMIN_WEB_ORIGIN=http://localhost:5173 VITE_ADMIN_WEB_ORIGIN=http://localhost:5173
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/client-web.iml" filepath="$PROJECT_DIR$/.idea/client-web.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+2 -1
View File
@@ -47,7 +47,8 @@
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` | | 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` | | 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` | | 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、订单、入仓码/SKU 查询、箱唛与履约进度 | `src/modules/first-leg/`;列表与查询从 `views/FirstLegOrdersView.vue`、`store/firstLegOrders.ts`、`api/unifiedOrders.ts` 进入,箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/`、`firstleg/`、`warehouse/`;稳定箱码与打印契约先读 Backend `firstleg/AGENTS.md` | | 头程下单、订单、入仓码业务关系、箱唛与履约进度 | `src/modules/first-leg/`;列表从 `views/FirstLegOrdersView.vue` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 |
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
| 客户财务账单、收款、未收余额与客户确认 | `src/modules/finance/`;确认入口 `views/FinanceBillsView.vue`、`api/finance.ts` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 | | 客户财务账单、收款、未收余额与客户确认 | `src/modules/finance/`;确认入口 `views/FinanceBillsView.vue`、`api/finance.ts` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 |
| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 | | 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 |
+2 -2
View File
@@ -35,7 +35,7 @@ pnpm test
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
@@ -47,4 +47,4 @@ pnpm test
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://8.163.30.128:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。2026-07-31 测试 Gateway 登录和真实角色列表读取正常;Warehouse 服务已注册,但仓库和往来单位业务查询仍返回 `S0500`,头程草稿创建和相关浏览器流程需待后端修复后继续验收。 联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
+3 -5
View File
@@ -4,7 +4,7 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-http://8.163.30.128:9001}" VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}"
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: client-web-test:local image: client-web-test:local
@@ -12,8 +12,6 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${HOST_PORT:-9002}:80" - "${HOST_PORT:-9002}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。 API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
+14 -2
View File
@@ -13,10 +13,22 @@ server {
# 前端统一请求 /api,保持原始路径转发给 Gateway。 # 前端统一请求 /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-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。
location /overseas-api/ {
client_max_body_size 55m;
rewrite ^/overseas-api/(.*)$ /api/$1 break;
proxy_pass ${OVERSEAS_API_UPSTREAM};
proxy_http_version 1.1;
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 -3
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { coordinateCrossTabRefresh } from './crossTabRefresh.ts' import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh.ts'
test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => { test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => {
let currentRefreshToken = 'refresh-old' let currentRefreshToken = 'refresh-old'
@@ -10,7 +10,7 @@ test('reuses tokens rotated by another browser tab while waiting for the refresh
configurable: true, configurable: true,
value: { value: {
locks: { locks: {
async request(_name, callback) { async request(_name, _options, callback) {
currentRefreshToken = 'refresh-new' currentRefreshToken = 'refresh-new'
return callback() return callback()
}, },
@@ -41,7 +41,7 @@ test('refreshes normally when this tab owns the current refresh token', async ()
const originalNavigator = globalThis.navigator const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', { Object.defineProperty(globalThis, 'navigator', {
configurable: true, configurable: true,
value: { locks: { request: async (_name, callback) => callback() } }, value: { locks: { request: async (_name, _options, callback) => callback() } },
}) })
try { try {
@@ -76,3 +76,32 @@ test('does not wait for rotation after the shared session was removed', async ()
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
} }
}) })
test('aborts a refresh lock that cannot be acquired', async () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
locks: {
request(_name, options) {
return new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true })
})
},
},
},
})
try {
await assert.rejects(coordinateCrossTabRefresh({
lockName: 'client-refresh',
lockTimeoutMs: 5,
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: async () => 'unexpected',
refresh: async () => 'unexpected',
}), CrossTabRefreshTimeoutError)
} finally {
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
}
})
+20 -1
View File
@@ -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)
}
} }
+34 -2
View File
@@ -3,7 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh' import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
const transport = axios.create({ const transport = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -18,7 +18,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
function unwrap<T>(envelope: ApiEnvelope<T>) { function unwrap<T>(envelope: ApiEnvelope<T>) {
if (envelope.code !== '200') { if (envelope.code !== '200') {
const fieldErrors = asFieldErrors(envelope.data) const fieldErrors = asFieldErrors(envelope.data)
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId) throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
} }
return envelope.data return envelope.data
} }
@@ -36,6 +36,7 @@ function mapError(error: unknown) {
error.response?.status, error.response?.status,
fieldErrors, fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null, typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
) )
} }
if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR') if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR')
@@ -78,6 +79,16 @@ export async function refreshAuthenticatedSession() {
return tokens.accessToken return tokens.accessToken
}, },
}) })
.catch((error) => {
if (error instanceof CrossTabRefreshTimeoutError) {
const expiredError = new ApiError(error.message, 'A0401', 401)
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()
throw expiredError
}
throw error
})
.finally(() => { .finally(() => {
refreshPromise = null refreshPromise = null
}) })
@@ -132,6 +143,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' }) const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return response.data return response.data
} catch (error) { } catch (error) {
await throwBlobApiError(error)
throw mapError(error) throw mapError(error)
} }
} }
@@ -141,8 +153,28 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' }) const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return { blob: response.data, headers: response.headers } return { blob: response.data, headers: response.headers }
} catch (error) { } catch (error) {
await throwBlobApiError(error)
throw mapError(error) throw mapError(error)
} }
} }
async function throwBlobApiError(error: unknown) {
if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return
try {
const body = JSON.parse(await error.response.data.text()) as unknown
if (!isApiEnvelope(body)) return
const fieldErrors = asFieldErrors(body.data)
throw new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
error.response.status,
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
} catch (parsedError) {
if (parsedError instanceof ApiError) throw parsedError
}
}
export { ApiError } export { ApiError }
+1
View File
@@ -18,6 +18,7 @@ export class ApiError extends Error {
readonly status?: number, readonly status?: number,
readonly fieldErrors?: ApiFieldErrors, readonly fieldErrors?: ApiFieldErrors,
readonly traceId?: string | null, readonly traceId?: string | null,
readonly requestId?: string | null,
) { ) {
super(message) super(message)
this.name = 'ApiError' this.name = 'ApiError'
+14 -2
View File
@@ -3,11 +3,23 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8') const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
test('allows qualification uploads with multipart overhead through the API proxy', () => { test('allows 50 MiB uploads with multipart overhead through both API proxies', () => {
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/) assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/)
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/)
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
})
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/)
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/)
assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/)
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/)
assert.match(config, /proxy_set_header Host \$proxy_host;/)
assert.doesNotMatch(config, /\/api\/api/)
}) })
test('runs each deployment on its target node without controller-local SSH files', () => { test('runs each deployment on its target node without controller-local SSH files', () => {
@@ -87,6 +87,9 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(httpSource, /return await transport\.request\(request\)/) assert.match(httpSource, /return await transport\.request\(request\)/)
assert.match(httpSource, /envelope\.code !== '200'/) assert.match(httpSource, /envelope\.code !== '200'/)
assert.match(httpSource, /redirectToLoginAfterSessionExpiry/) assert.match(httpSource, /redirectToLoginAfterSessionExpiry/)
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/)
assert.match(httpSource, /const expiredError = new ApiError\(error\.message, 'A0401', 401\)/)
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/)
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
assert.match(authStoreSource, /decodeMerchantToken/) assert.match(authStoreSource, /decodeMerchantToken/)
assert.doesNotMatch(authStoreSource, /mock-client:/) assert.doesNotMatch(authStoreSource, /mock-client:/)
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./customs.ts', import.meta.url), 'utf8')
const httpTypes = readFileSync(new URL('../../../api/types.ts', import.meta.url), 'utf8')
const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8')
test('maps only the frozen merchant container-customs contract', () => {
assert.match(source, /type MerchantCustomsStatus = 'WAITING_UPLOAD' \| 'DOCUMENT_UPLOADED' \| 'DECLARING' \| 'CUSTOMS_COMPLETED'/)
assert.match(source, /inboundId: string/)
assert.match(source, /orderId: string/)
assert.match(source, /documentId: string/)
assert.match(source, /loadingPlanId: string/)
assert.match(source, /getMerchantCustomsForOrder\(orderId: string\)/)
assert.doesNotMatch(source, /number \| string/)
assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/)
assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/)
})
test('sends multi-inbound scope, optimistic versions and idempotency for every write', () => {
assert.match(source, /params\.append\('inboundIds', id\)/)
assert.match(source, /expectedVersion: document\.source\.version/)
assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/)
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4)
assert.match(source, /data\.append\('file', file\)/)
assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/)
})
test('preserves request and trace identifiers on API errors', () => {
assert.match(httpTypes, /readonly requestId\?: string \| null/)
assert.match(httpSource, /envelope\.traceId, envelope\.requestId/)
assert.match(source, /interface ApiErrorMeta/)
assert.match(source, /requestId: error\.requestId \?\? null/)
assert.match(source, /traceId: error\.traceId \?\? null/)
})
+198 -19
View File
@@ -1,31 +1,210 @@
import { ApiError, apiRequest } from '@/api/http' import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http'
import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg'
export interface MerchantCustomsReceipt { export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED'
id: number | string export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string
receiptVersion: number | null export interface MerchantCustomsFile {
fileName: string | null fileId: string
uploadedAt: string | null fileName: string
fileHash: string
contentType: string | null
sizeBytes: number
version: number
uploadedAt: string
} }
export interface MerchantCustomsCaseDetail { export interface MerchantCustomsInbound {
customsCase: { inboundId: string
id: number | string orderId: string
customsNo: string | null orderNo: string
orderId: number | string inboundCode: string
status: string | null tenantId: string
updatedAt: string | null customerName: string
} taxMode: 'TAX_REFUND' | 'BUY_DECLARATION'
receipts: MerchantCustomsReceipt[] covered: boolean
} }
export function getMerchantCustomsByOrder(orderId: number | string) { export interface MerchantCustomsDocument {
return apiRequest<MerchantCustomsCaseDetail>({ documentId: string
tenantId: string
source: MerchantCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: MerchantCustomsFile | null
}
export interface MerchantCustomsContainer {
aggregateId: string
loadingPlanId: string
containerBookingId: string
planNo?: string
cabinetNo?: string
containerNo: string | null
status: ContainerCustomsStatus
merchantStatus?: MerchantCustomsStatus | null
version: number
taxRefundCount: number
coveredTaxRefundCount: number
inbounds: MerchantCustomsInbound[]
documents: MerchantCustomsDocument[]
startedAt?: string | null
completedAt?: string | null
}
export interface ApiErrorMeta {
code: string
requestId: string | null
traceId: string | null
}
export interface MerchantCustomsDownload {
blob: Blob
fileName: string
}
export const MERCHANT_CUSTOMS_STATUS_LABELS: Record<MerchantCustomsStatus, string> = {
WAITING_UPLOAD: '待上传资料',
DOCUMENT_UPLOADED: '资料已上传',
DECLARING: '报关中',
CUSTOMS_COMPLETED: '报关完成',
}
const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024
function encodedId(value: string) {
return encodeURIComponent(value)
}
function inboundQuery(inboundIds: string[]) {
const params = new URLSearchParams()
inboundIds.forEach((id) => params.append('inboundIds', id))
return params.toString()
}
function fileBody(file: File) {
const data = new FormData()
data.append('file', file)
return data
}
export function validateMerchantCustomsFile(file: File) {
if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB')
}
export function getMerchantCustomsForOrder(orderId: string) {
return apiRequest<MerchantCustomsContainer>({
method: 'GET', method: 'GET',
url: `/merchant/v1/customs/orders/${orderId}`, url: `/merchant/v1/customs/orders/${encodedId(orderId)}`,
}) })
} }
export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) {
validateMerchantCustomsFile(file)
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/loading-plans/${encodedId(planId)}/documents/upload?${inboundQuery(inboundIds)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-upload-${planId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: string[], file: File) {
validateMerchantCustomsFile(file)
const params = new URLSearchParams(inboundQuery(inboundIds))
params.set('expectedVersion', String(document.source.version))
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/upload?${params.toString()}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-replace-${document.documentId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: string[]) {
return apiRequest<MerchantCustomsDocument>({
method: 'PUT',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) },
data: { inboundIds, expectedVersion: document.source.version },
})
}
export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) {
return apiRequest<MerchantCustomsContainer>({
method: 'DELETE',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-delete-${document.documentId}`) },
params: { expectedVersion: document.source.version },
})
}
function contentDispositionFileName(value: unknown, fallback: string) {
if (typeof value !== 'string') return fallback
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try { return decodeURIComponent(encoded) } catch { return fallback }
}
return value.match(/filename="?([^";]+)"?/i)?.[1] || fallback
}
async function requireBinaryDownload(blob: Blob) {
if (!blob.type.toLowerCase().includes('json')) return
try {
const body = JSON.parse(await blob.text()) as { code?: unknown; message?: unknown; requestId?: unknown; traceId?: unknown }
if (typeof body.message === 'string') {
throw new ApiError(
body.message,
typeof body.code === 'string' ? body.code : 'DOWNLOAD_FAILED',
undefined,
undefined,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
}
} catch (error) {
if (error instanceof ApiError) throw error
}
}
async function download(url: string, fallbackName: string): Promise<MerchantCustomsDownload> {
const response = await apiBlobResponseRequest({ method: 'GET', url })
await requireBinaryDownload(response.blob)
return {
blob: response.blob,
fileName: contentDispositionFileName(response.headers['content-disposition'], fallbackName),
}
}
export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) {
return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName)
}
export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) {
return download(
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`,
document.completion?.fileName || `customs-completion-${document.documentId}`,
)
}
export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null {
if (!(error instanceof ApiError)) return null
return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null }
}
export function merchantCustomsErrorMessage(error: unknown, fallback: string) {
if (!(error instanceof ApiError)) return error instanceof Error ? error.message : fallback
const identifiers = [
error.code ? `错误码 ${error.code}` : '',
error.requestId ? `请求号 ${error.requestId}` : '',
error.traceId ? `追踪号 ${error.traceId}` : '',
].filter(Boolean)
return `${error.message || fallback}${identifiers.length ? `(${identifiers.join(',')})` : ''}`
}
export function isMerchantCustomsUnavailable(error: unknown) { export function isMerchantCustomsUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
} }
+1
View File
@@ -238,6 +238,7 @@ export interface MerchantFirstLegOrderRow {
inboundCode: string | null inboundCode: string | null
channelName: string | null channelName: string | null
destinationType: ApiDestinationType | null destinationType: ApiDestinationType | null
pickupMethod: ApiPickupMethod | null
customerName: string | null customerName: string | null
receivingCompanyName: string | null receivingCompanyName: string | null
specificationModel: string | null specificationModel: string | null
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./inboundCodeAssociations.ts', import.meta.url), 'utf8')
test('queries the customer-scoped inbound-code association without browser-selected customer scope', () => {
assert.match(source, /method: 'GET'/)
assert.match(source, /`\/merchant\/v1\/orders\/first-leg\/inbound-code-associations\/\$\{encodeURIComponent\(inboundCode\.trim\(\)\)\}`/)
assert.doesNotMatch(source, /params:[\s\S]*customerId/)
})
test('keeps internal association identifiers as strings without making them display keys', () => {
for (const field of ['orderId', 'customerId']) assert.match(source, new RegExp(`${field}: string`))
assert.match(source, /destinationWarehouseId: string \| null/)
for (const field of ['inboundCode', 'orderNo', 'customerName', 'destinationWarehouseCode', 'destinationWarehouseName']) {
assert.match(source, new RegExp(`${field}: string`))
}
})
@@ -0,0 +1,22 @@
import { apiRequest } from '@/api/http'
export interface MerchantInboundCodeAssociation {
inboundCode: string
orderNo: string
orderStatus: string
orderId: string
customerId: string
customerName: string
destinationType: string
destinationCountryCode: string | null
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
}
export function getMerchantInboundCodeAssociation(inboundCode: string) {
return apiRequest<MerchantInboundCodeAssociation>({
method: 'GET',
url: `/merchant/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode.trim())}`,
})
}
@@ -0,0 +1,341 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
import {
MERCHANT_CUSTOMS_STATUS_LABELS,
deleteMerchantCustomsDocument,
downloadMerchantCustomsCompletion,
downloadMerchantCustomsSource,
getMerchantCustomsForOrder,
merchantCustomsErrorMessage,
replaceMerchantCustomsDocument,
updateMerchantCustomsDocumentInbounds,
uploadMerchantCustomsDocument,
validateMerchantCustomsFile,
type MerchantCustomsContainer,
type MerchantCustomsDocument,
} from '@/modules/first-leg/api/customs'
const props = defineProps<{
modelValue: boolean
orderId: string
canEdit: boolean
initialDetail?: MerchantCustomsContainer | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [value: MerchantCustomsContainer]
}>()
const detail = ref<MerchantCustomsContainer | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const selectedInboundIds = ref<string[]>([])
const editingDocument = ref<MerchantCustomsDocument | null>(null)
const editingInboundIds = ref<string[]>([])
const replacingDocument = ref<MerchantCustomsDocument | null>(null)
const uploadInput = ref<HTMLInputElement>()
const replaceInput = ref<HTMLInputElement>()
const mutating = ref(false)
const downloadingKey = ref('')
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
const merchantStatusLabel = computed(() => detail.value?.merchantStatus
? MERCHANT_CUSTOMS_STATUS_LABELS[detail.value.merchantStatus]
: '待上传资料')
const statusTagType = computed(() => ({
WAITING_UPLOAD: 'warning',
DOCUMENT_UPLOADED: 'success',
DECLARING: 'primary',
CUSTOMS_COMPLETED: 'success',
} as const)[detail.value?.merchantStatus ?? 'WAITING_UPLOAD'])
function initializeSelection(value: MerchantCustomsContainer) {
const currentOrderIds = value.inbounds
.filter((item) => item.taxMode === 'TAX_REFUND' && item.orderId === props.orderId)
.map((item) => item.inboundId)
selectedInboundIds.value = currentOrderIds
}
async function loadDetail() {
if (!props.orderId) return
loading.value = true
errorMessage.value = ''
try {
const result = await getMerchantCustomsForOrder(props.orderId)
detail.value = result
initializeSelection(result)
emit('updated', result)
} catch (error) {
detail.value = null
errorMessage.value = merchantCustomsErrorMessage(error, '退税报关资料加载失败')
} finally {
loading.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
detail.value = props.initialDetail ?? null
if (detail.value) initializeSelection(detail.value)
void loadDetail()
})
function close() {
emit('update:modelValue', false)
}
function chooseUpload() {
if (!selectedInboundIds.value.length) {
ElMessage.warning('请先选择至少一个退税入仓码')
return
}
uploadInput.value?.click()
}
async function handleUploadFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !detail.value) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await uploadMerchantCustomsDocument(detail.value.loadingPlanId, selectedInboundIds.value, file)
ElMessage.success('资料上传成功')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料上传失败'))
} finally {
mutating.value = false
}
}
function beginEditInbounds(document: MerchantCustomsDocument) {
editingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
}
async function saveInbounds() {
if (!editingDocument.value || !editingInboundIds.value.length) {
ElMessage.warning('请至少保留一个关联入仓码')
return
}
try {
mutating.value = true
await updateMerchantCustomsDocumentInbounds(editingDocument.value, editingInboundIds.value)
editingDocument.value = null
ElMessage.success('关联入仓码已更新')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '关联入仓码更新失败'))
} finally {
mutating.value = false
}
}
async function chooseReplacement(document: MerchantCustomsDocument) {
replacingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
await nextTick()
replaceInput.value?.click()
}
async function handleReplacementFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const document = replacingDocument.value
replacingDocument.value = null
if (!file || !document) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await replaceMerchantCustomsDocument(document, editingInboundIds.value, file)
ElMessage.success('资料已覆盖并生成新版本')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料覆盖失败'))
} finally {
mutating.value = false
}
}
async function removeDocument(document: MerchantCustomsDocument) {
try {
await ElMessageBox.confirm(`确认删除误传资料“${document.source.fileName}”吗?历史版本仍会由系统保留。`, '删除资料', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
mutating.value = true
await deleteMerchantCustomsDocument(document)
ElMessage.success('资料已删除')
await loadDetail()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(merchantCustomsErrorMessage(error, '资料删除失败'))
} finally {
mutating.value = false
}
}
function saveDownload(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: MerchantCustomsDocument, kind: 'source' | 'completion') {
const key = `${document.documentId}:${kind}`
downloadingKey.value = key
try {
const result = kind === 'source'
? await downloadMerchantCustomsSource(document)
: await downloadMerchantCustomsCompletion(document)
saveDownload(result.blob, result.fileName)
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
} finally {
if (downloadingKey.value === key) downloadingKey.value = ''
}
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="customs-documents-dialog"
title="退税报关资料"
width="920px"
append-to-body
destroy-on-close
@close="close"
>
<el-skeleton v-if="loading && !detail" :rows="6" animated />
<div v-else-if="errorMessage" class="customs-error" role="alert">
<strong>退税报关资料加载失败</strong>
<span>{{ errorMessage }}</span>
<el-button :loading="loading" @click="loadDetail">重新加载</el-button>
</div>
<template v-else-if="detail">
<header class="customs-summary">
<div>
<span>柜次</span>
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}</strong>
</div>
<div>
<span>资料覆盖</span>
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
</div>
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
</header>
<div v-if="locked" class="customs-locked" role="status">
<LockKeyhole :size="18" />
<div><strong>报关资料已锁定</strong><span>报关开始后仅可查看和下载,不提供退回或解锁。</span></div>
</div>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>上传资料</h3><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></div>
<el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button>
</div>
<el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码">
<el-checkbox v-for="item in taxRefundInbounds" :key="item.inboundId" :value="item.inboundId">
<span class="customs-inbound-code">{{ item.inboundCode }}</span>
<!-- <small>{{ item.orderNo }}</small> -->
</el-checkbox>
</el-checkbox-group>
<input ref="uploadInput" class="customs-hidden-input" type="file" @change="handleUploadFile">
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
</section>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
</div>
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
<div v-else class="customs-document-list">
<article v-for="item in detail.documents" :key="item.documentId" class="customs-document-card">
<div class="customs-document-main">
<FileText :size="20" />
<div>
<strong>{{ item.source.fileName }}</strong>
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
</div>
</div>
<div class="customs-document-actions">
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
<el-button v-if="detail.status === 'COMPLETED' && item.completion" link type="success" :loading="downloadingKey === `${item.documentId}:completion`" @click="downloadFile(item, 'completion')"><Download :size="15" />完成文件</el-button>
<template v-if="canEdit && !locked && !item.frozen">
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
<el-button link type="primary" :disabled="mutating" @click="chooseReplacement(item)"><Upload :size="15" />覆盖</el-button>
<el-button link type="danger" :disabled="mutating" @click="removeDocument(item)"><Trash2 :size="15" />删除</el-button>
</template>
</div>
<div v-if="editingDocument?.documentId === item.documentId" class="customs-scope-editor">
<el-checkbox-group v-model="editingInboundIds" aria-label="修改资料关联入仓码">
<el-checkbox v-for="inbound in taxRefundInbounds" :key="inbound.inboundId" :value="inbound.inboundId">{{ inbound.inboundCode }}</el-checkbox>
</el-checkbox-group>
<div><el-button @click="editingDocument = null">取消</el-button><el-button type="primary" :loading="mutating" @click="saveInbounds">保存关联</el-button></div>
</div>
</article>
</div>
</section>
</template>
<template #footer><el-button @click="close">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.customs-error { display: grid; gap: 10px; justify-items: start; padding: 22px; border: 1px solid #f1b8b8; background: #fff7f7; color: #9f2d2d; }
.customs-error span { overflow-wrap: anywhere; font-size: 12px; }
.customs-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; align-items: center; gap: 18px; padding: 16px 18px; border: 1px solid var(--border); background: #f8faf9; }
.customs-summary div { min-width: 0; }
.customs-summary span { display: block; color: var(--muted); font-size: 11px; }
.customs-summary strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; }
.customs-locked { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 13px 15px; border: 1px solid #d5dde1; background: #f5f7f8; color: var(--muted); }
.customs-locked strong, .customs-locked span { display: block; }
.customs-locked strong { color: var(--navy); font-size: 12px; }
.customs-locked span { margin-top: 3px; font-size: 11px; }
.customs-section { margin-top: 18px; }
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); }
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }
.customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.customs-inbound-options small { margin-left: 7px; color: var(--muted); }
.customs-hidden-input { display: none; }
.customs-document-list { display: grid; gap: 10px; }
.customs-document-card { padding: 14px 16px; border: 1px solid var(--border); }
.customs-document-main { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.customs-document-main > svg { flex: 0 0 20px; color: var(--primary); }
.customs-document-main > div { min-width: 0; }
.customs-document-main strong, .customs-document-main span, .customs-document-main small { display: block; overflow-wrap: anywhere; }
.customs-document-main strong { color: var(--navy); font-size: 13px; }
.customs-document-main span, .customs-document-main small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.customs-document-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px; margin-top: 8px; }
.customs-scope-editor { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
.customs-scope-editor :deep(.el-checkbox) { margin-right: 16px; }
</style>
@@ -19,7 +19,7 @@ test('shows the confirmed merchant inbound and fee fields without write actions'
assert.match(source, new RegExp(label)) assert.match(source, new RegExp(label))
} }
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
assert.match(source, /getRoundingModeLabel\(props\.data\.latestFee\.roundingMode\)/) assert.match(source, /formatConversionPrecision\(/)
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/) assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
assert.doesNotMatch(source, /internalNotes|statusHistory|files/) assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
assert.match(source, /latestFee\.ruleCode/) assert.match(source, /latestFee\.ruleCode/)
@@ -2,12 +2,12 @@
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue' import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling' import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import { import {
formatConversionPrecision,
formatInboundDateTime, formatInboundDateTime,
formatInboundMeasure, formatInboundMeasure,
formatInboundMoney, formatInboundMoney,
formatInboundNumber, formatInboundNumber,
getChargeModeLabel, getChargeModeLabel,
getRoundingModeLabel,
getTaxModeLabel, getTaxModeLabel,
} from '@/modules/first-leg/utils/inboundBilling' } from '@/modules/first-leg/utils/inboundBilling'
@@ -109,10 +109,11 @@ defineEmits<{
<div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div> <div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div>
<div> <div>
<dt>换算与精度</dt> <dt>换算与精度</dt>
<dd> <dd>{{ formatConversionPrecision(
系数 {{ formatInboundNumber(props.data.latestFee.conversionFactor) }} · props.data.latestFee.conversionFactor,
保留 {{ props.data.latestFee.roundingScale }} 位 {{ getRoundingModeLabel(props.data.latestFee.roundingMode) }} props.data.latestFee.roundingScale,
</dd> props.data.latestFee.roundingMode,
) }}</dd>
</div> </div>
<div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div> <div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
<div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div> <div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div>
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./InboundCodeAssociationDialog.vue', import.meta.url), 'utf8')
test('loads only the customer-scoped server association and fails closed', () => {
assert.match(source, /getMerchantInboundCodeAssociation\(inboundCode\)/)
assert.match(source, /association\.value = null/)
assert.match(source, /关联摘要加载失败/)
assert.match(source, /@click="loadAssociation"/)
assert.doesNotMatch(source, /orderId\)|customerId\)|destinationWarehouseId\)/)
})
test('renders business identifiers and names without internal long identifiers', () => {
for (const label of ['入仓码', '订单号', '订单状态', '客户名称', '目的国家', '目的仓']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /association\.inboundCode/)
assert.match(source, /association\.orderNo/)
assert.match(source, /association\.customerName/)
assert.match(source, /destinationWarehouseCode/)
assert.match(source, /destinationWarehouseName/)
assert.doesNotMatch(source, /association\.(?:orderId|customerId|destinationWarehouseId)/)
assert.match(source, /\|\| '—'/)
})
test('uses a bounded centered summary dialog suitable for the desktop detail flow', () => {
assert.match(source, /<el-dialog[^>]*width="620px"/)
assert.match(source, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/)
assert.match(source, /min-height: 190px/)
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AlertTriangle, Link2, RefreshCw } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared'
import {
getMerchantInboundCodeAssociation,
type MerchantInboundCodeAssociation,
} from '@/modules/first-leg/api/inboundCodeAssociations'
const props = defineProps<{
modelValue: boolean
inboundCode: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const loading = ref(false)
const error = ref('')
const association = ref<MerchantInboundCodeAssociation | null>(null)
let requestSequence = 0
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
function displayValue(value: string | null | undefined) {
return value?.trim() || '—'
}
function statusLabel(value: string) {
return FIRST_LEG_ORDER_STATUSES.find((item) => item.code === value)?.label ?? displayValue(value)
}
function destinationLabel(value: MerchantInboundCodeAssociation) {
return [value.destinationWarehouseCode, value.destinationWarehouseName].filter(Boolean).join(' · ') || '—'
}
async function loadAssociation() {
const inboundCode = props.inboundCode.trim()
const sequence = ++requestSequence
association.value = null
error.value = ''
loading.value = false
if (!inboundCode) {
error.value = '当前订单暂无可查询的入仓码'
return
}
loading.value = true
try {
const result = await getMerchantInboundCodeAssociation(inboundCode)
if (sequence === requestSequence && props.modelValue) association.value = result
} catch (reason) {
if (sequence === requestSequence && props.modelValue) {
error.value = reason instanceof Error ? reason.message : '入仓码关联查询失败'
}
} finally {
if (sequence === requestSequence) loading.value = false
}
}
watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
if (open) void loadAssociation()
else {
requestSequence += 1
loading.value = false
error.value = ''
association.value = null
}
}, { immediate: true })
</script>
<template>
<el-dialog v-model="visible" class="inbound-association-dialog" title="入仓码关联摘要" width="620px" append-to-body destroy-on-close>
<div v-loading="loading" class="inbound-association-content">
<div v-if="error" class="inbound-association-error" role="alert">
<AlertTriangle :size="22" />
<div><strong>关联摘要加载失败</strong><span>{{ error }}</span></div>
<el-button :loading="loading" @click="loadAssociation"><RefreshCw :size="15" />重新加载</el-button>
</div>
<template v-else-if="association">
<header class="inbound-association-heading">
<Link2 :size="21" />
<div><span>入仓码</span><strong>{{ displayValue(association.inboundCode) }}</strong></div>
</header>
<dl class="inbound-association-grid">
<div><dt>订单号</dt><dd>{{ displayValue(association.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ statusLabel(association.orderStatus) }}</dd></div>
<div><dt>客户名称</dt><dd>{{ displayValue(association.customerName) }}</dd></div>
<div><dt>目的国家</dt><dd>{{ displayValue(association.destinationCountryCode) }}</dd></div>
<div class="is-wide"><dt>目的仓</dt><dd>{{ destinationLabel(association) }}</dd></div>
</dl>
</template>
</div>
<template #footer><el-button @click="visible = false">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.inbound-association-content { min-height: 190px; }
.inbound-association-error { display: grid; min-height: 170px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 20px; border: 1px solid #efc1bc; background: #fff8f7; color: #a33b32; }
.inbound-association-error > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.inbound-association-error span { overflow-wrap: anywhere; font-size: 12px; }
.inbound-association-heading { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.inbound-association-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.inbound-association-heading span { color: var(--muted); font-size: 11px; }
.inbound-association-heading strong { overflow-wrap: anywhere; color: var(--navy); font-size: 16px; }
.inbound-association-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.inbound-association-grid > div { min-width: 0; padding: 13px 14px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.inbound-association-grid .is-wide { grid-column: 1 / -1; }
.inbound-association-grid dt { color: var(--muted); font-size: 11px; }
.inbound-association-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; font-weight: 650; }
</style>
@@ -12,13 +12,14 @@ test('maps the first-leg aggregate list fields without loading detail rows', ()
assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/) assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/)
assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/) assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
assert.match(storeSource, /function mapFirstLegOrderRow/) assert.match(storeSource, /function mapFirstLegOrderRow/)
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) { for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) {
assert.match(storeSource, new RegExp(`row\\.${field}`)) assert.match(storeSource, new RegExp(`row\\.${field}`))
} }
assert.match(storeSource, /row\.skuNos/) assert.match(storeSource, /row\.skuNos/)
assert.match(storeSource, /row\.cargoes/) assert.match(storeSource, /row\.cargoes/)
assert.match(storeSource, /chargeableWeightKg: null/) assert.match(storeSource, /chargeableWeightKg: null/)
assert.match(storeSource, /chargeableVolumeCbm: null/) assert.match(storeSource, /chargeableVolumeCbm: null/)
assert.match(apiSource, /pickupMethod: ApiPickupMethod \\| null/)
assert.match(storeSource, /orderResult\.value\.records\.map\(mapFirstLegOrderRow\)/) assert.match(storeSource, /orderResult\.value\.records\.map\(mapFirstLegOrderRow\)/)
assert.match(storeSource, /orderResult\.value\.records\.map\(\(item\) => mapUnifiedOrderRow\(item, warehouses\.value\)\)/) assert.match(storeSource, /orderResult\.value\.records\.map\(\(item\) => mapUnifiedOrderRow\(item, warehouses\.value\)\)/)
}) })
@@ -398,7 +398,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '', inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '',
}, },
createdAt: row.createdAt, updatedAt: row.createdAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: '', createdAt: row.createdAt, updatedAt: row.createdAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: '',
destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '', destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: row.pickupMethod ?? 'SELF_EXPRESS', channelCode: '',
channelName: row.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', channelName: row.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION',
customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '', customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '',
shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', requestedPickupAt: '', receivingTime: '', submittedAt: '', completedAt: '', shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', requestedPickupAt: '', receivingTime: '', submittedAt: '', completedAt: '',
@@ -79,9 +79,9 @@ export interface MerchantInboundFeeSnapshot {
destinationCode: string destinationCode: string
taxMode: string taxMode: string
chargeMode: 'KG' | 'CBM' chargeMode: 'KG' | 'CBM'
conversionFactor: number conversionFactor: number | null
roundingScale: number roundingScale: number | null
roundingMode: string roundingMode: string | null
actualWeightKg: number actualWeightKg: number
actualVolumeCbm: number actualVolumeCbm: number
convertedQuantity: number convertedQuantity: number
@@ -108,7 +108,7 @@ test('only requires sender for pickup and receiver for a private destination', (
assert.ok(validateFirstLegOrderDraft(privateDraft).includes('请填写收件人姓名')) assert.ok(validateFirstLegOrderDraft(privateDraft).includes('请填写收件人姓名'))
}) })
test('requires FBA warehouse and cargo-level shipment, reference, SKU and UPC fields', () => { test('requires FBA warehouse and cargo-level shipment, reference and SKU while keeping UPC optional', () => {
const draft = validDraft() const draft = validDraft()
draft.referenceNo = '' draft.referenceNo = ''
draft.amazonWarehouseCode = '' draft.amazonWarehouseCode = ''
@@ -121,7 +121,7 @@ test('requires FBA warehouse and cargo-level shipment, reference, SKU and UPC fi
const errors = validateFirstLegOrderDraft(draft) const errors = validateFirstLegOrderDraft(draft)
assert.ok(errors.includes('FBA 订单请填写 Amazon 仓库编码')) assert.ok(errors.includes('FBA 订单请填写 Amazon 仓库编码'))
assert.ok(errors.includes('第 1 条货物缺少客户 SKU')) assert.ok(errors.includes('第 1 条货物缺少客户 SKU'))
assert.ok(errors.includes('第 1 条货物缺少 UPC')) assert.ok(!errors.includes('第 1 条货物缺少 UPC'))
assert.ok(errors.includes('第 1 条货物缺少货件编号')) assert.ok(errors.includes('第 1 条货物缺少货件编号'))
assert.ok(errors.includes('第 1 条货物缺少跟踪编号')) assert.ok(errors.includes('第 1 条货物缺少跟踪编号'))
}) })
@@ -122,7 +122,6 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`) if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`) if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`) if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`)
if (draft.destinationType === 'FBA' && !item.upc.trim()) errors.push(`${line}缺少 UPC`)
if (draft.destinationType === 'FBA' && !item.shipmentId.trim()) errors.push(`${line}缺少货件编号`) if (draft.destinationType === 'FBA' && !item.shipmentId.trim()) errors.push(`${line}缺少货件编号`)
if (draft.destinationType === 'FBA' && !item.referenceId.trim()) errors.push(`${line}缺少跟踪编号`) if (draft.destinationType === 'FBA' && !item.referenceId.trim()) errors.push(`${line}缺少跟踪编号`)
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`) if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
@@ -16,5 +16,12 @@ test('translates backend rounding modes into customer-facing calculation labels'
assert.match(source, /export function getRoundingModeLabel/) assert.match(source, /export function getRoundingModeLabel/)
assert.match(source, /CEILING: '向上取整'/) assert.match(source, /CEILING: '向上取整'/)
assert.match(source, /HALF_UP: '四舍五入'/) assert.match(source, /HALF_UP: '四舍五入'/)
assert.match(source, /return labels\[value\] \|\| value \|\| '-'/) assert.match(source, /return value \? labels\[value\] \|\| value : '-'/)
})
test('renders conversion metadata as one complete label or a single empty marker', () => {
assert.match(source, /export function formatConversionPrecision/)
assert.match(source, /roundingScale === null \|\| roundingScale === undefined/)
assert.match(source, /\|\| !roundingMode\) return '-'/)
assert.match(source, /`系数 \$\{formatInboundNumber\(conversionFactor\)\} · 保留 \$\{roundingScale\} 位 \$\{getRoundingModeLabel\(roundingMode\)\}`/)
}) })
+13 -2
View File
@@ -46,7 +46,7 @@ export function getTaxModeLabel(value: string) {
return value || '-' return value || '-'
} }
export function getRoundingModeLabel(value: string) { export function getRoundingModeLabel(value: string | null | undefined) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
UP: '向上舍入', UP: '向上舍入',
DOWN: '向下舍入', DOWN: '向下舍入',
@@ -56,5 +56,16 @@ export function getRoundingModeLabel(value: string) {
HALF_DOWN: '五舍六入', HALF_DOWN: '五舍六入',
HALF_EVEN: '银行家舍入', HALF_EVEN: '银行家舍入',
} }
return labels[value] || value || '-' return value ? labels[value] || value : '-'
}
export function formatConversionPrecision(
conversionFactor: number | null | undefined,
roundingScale: number | null | undefined,
roundingMode: string | null | undefined,
) {
if (conversionFactor === null || conversionFactor === undefined
|| roundingScale === null || roundingScale === undefined
|| !roundingMode) return '-'
return `系数 ${formatInboundNumber(conversionFactor)} · 保留 ${roundingScale} 位 ${getRoundingModeLabel(roundingMode)}`
} }
@@ -6,6 +6,8 @@ const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url)
const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8') const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8')
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8') const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => { test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/) assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
@@ -32,7 +34,7 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
assert.match(source, /function submitOrder\(\)/) assert.match(source, /function submitOrder\(\)/)
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/) assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
assert.match(source, /Promise\.allSettled\(\[/) assert.match(source, /Promise\.allSettled\(\[/)
assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\)/) assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\{ includeDrafts: true \}\)/)
assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/) assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/)
assert.match(source, /tenantStore\.loadCompanyContext\(\)/) assert.match(source, /tenantStore\.loadCompanyContext\(\)/)
assert.match(source, /await store\.loadDetail\(order\.id\)/) assert.match(source, /await store\.loadDetail\(order\.id\)/)
@@ -56,7 +58,7 @@ test('defers approval checks to the server when the member cannot read the compa
assert.match(source, /const canViewCompanyProfile = computed/) assert.match(source, /const canViewCompanyProfile = computed/)
assert.match(source, /const companySubmissionBlocked = computed\(\(\) => companyAuditLoaded\.value/) assert.match(source, /const companySubmissionBlocked = computed\(\(\) => companyAuditLoaded\.value/)
assert.match(source, /const knownCompanyAuditStatus = companyAuditLoaded\.value \? tenantStore\.company\.auditStatus : undefined/) assert.match(source, /const knownCompanyAuditStatus = companyAuditLoaded\.value \? tenantStore\.company\.auditStatus : undefined/)
assert.match(source, /store\.submit\([^\n]*knownCompanyAuditStatus\)/) assert.match(source, /store\.submitSaved\(savedOrder\.id, knownCompanyAuditStatus\)/)
}) })
test('renders dynamic cargo lines and a responsive editor dialog', () => { test('renders dynamic cargo lines and a responsive editor dialog', () => {
@@ -89,7 +91,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
assert.match(source, /async function searchOrders\(model: QueryModel\)/) assert.match(source, /async function searchOrders\(model: QueryModel\)/)
assert.match(source, /await store\.load\(\{/) assert.match(source, /await store\.load\(\{/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/) assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' \}/) assert.match(source, /\{ key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' \}/)
assert.doesNotMatch(source, /order-metrics|订单概览/) assert.doesNotMatch(source, /order-metrics|订单概览/)
}) })
@@ -198,7 +200,7 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/) assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /国内装柜进度及后续履约同步/) assert.match(progressSource, /国内装柜进度及后续履约同步/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/) assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.match(source, /报关回执/) assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
assert.match(source, /船期摘要/) assert.match(source, /船期摘要/)
assert.match(source, /柜次进度/) assert.match(source, /柜次进度/)
assert.doesNotMatch(source, /summary\.sealNo \|\|/) assert.doesNotMatch(source, /summary\.sealNo \|\|/)
@@ -221,9 +223,10 @@ test('uses only fields present on C02 rows before loading full detail', () => {
test('shows and searches the inbound code after a draft is submitted', () => { test('shows and searches the inbound code after a draft is submitted', () => {
assert.match(source, /label: '入仓码 \/ SKU'/) assert.match(source, /label: '入仓码 \/ SKU'/)
assert.match(source, /order\.businessSummary\.inboundCode/) assert.match(source, /order\.businessSummary\.inboundCode/)
assert.match(source, /<template #inboundCode="\{ row \}"><span class="table-ellipsis table-code"[^>]*>\{\{ displayListValue\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\) \}\}<\/span><\/template>/) assert.match(source, /<template #inboundCode="\{ row \}">[\s\S]*?class="inbound-code-link table-ellipsis table-code"/)
assert.match(source, /v-if="order\.businessSummary\.inboundCode"[^>]*>入仓码/) assert.match(source, /v-if="order\.businessSummary\.inboundCode" class="order-reference inbound-code-link"/)
assert.match(source, /v-if="detailOrder\.businessSummary\.inboundCode"><dt>入仓码<\/dt>/) assert.match(source, /<dt>入仓码<\/dt><dd><button v-if="detailOrder\.businessSummary\.inboundCode"/)
assert.match(source, /<span v-else>—<\/span>/)
}) })
test('keeps the seven-stage progress and business summary usable on mobile', () => { test('keeps the seven-stage progress and business summary usable on mobile', () => {
@@ -233,10 +236,12 @@ test('keeps the seven-stage progress and business summary usable on mobile', ()
}) })
test('keeps the first viewport compact and moves create into the table toolbar', () => { test('keeps the first viewport compact and moves create into the table toolbar', () => {
const queryToolbarSource = source.slice(source.indexOf('<QueryToolbar'), source.indexOf('</QueryToolbar>') + '</QueryToolbar>'.length)
assert.match(source, /class="order-table-toolbar"/) assert.match(source, /class="order-table-toolbar"/)
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/) assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/) assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
assert.doesNotMatch(source, /<QueryToolbar[\s\S]*?<template #actions>/) assert.match(queryToolbarSource, /<template #actions>[\s\S]*下载唛头[\s\S]*导出订单信息/)
assert.doesNotMatch(queryToolbarSource, /order:first-leg:create|创建订单/)
assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/) assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/)
}) })
@@ -316,6 +321,7 @@ test('collects the backend-required first-leg fields', () => {
}) })
test('distills order creation into the confirmed prototype sections with server pricing', () => { test('distills order creation into the confirmed prototype sections with server pricing', () => {
const cargoTableSource = source.slice(source.indexOf('<table class="cargo-edit-table">'), source.indexOf('</table>', source.indexOf('<table class="cargo-edit-table">')))
assert.match(source, /<h3>目的地信息<\/h3>/) assert.match(source, /<h3>目的地信息<\/h3>/)
assert.match(source, /<h3>客户下单<\/h3>/) assert.match(source, /<h3>客户下单<\/h3>/)
assert.match(source, /<h3>货物明细<\/h3>/) assert.match(source, /<h3>货物明细<\/h3>/)
@@ -323,8 +329,10 @@ test('distills order creation into the confirmed prototype sections with server
assert.ok(source.indexOf('<h3>目的地信息</h3>') < source.indexOf('<h3>货物明细</h3>')) assert.ok(source.indexOf('<h3>目的地信息</h3>') < source.indexOf('<h3>货物明细</h3>'))
assert.match(source, /class="segmented-options"/) assert.match(source, /class="segmented-options"/)
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/) assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
for (const heading of ['商品信息', '箱规、数量与重量', '报关与产品资料', '货件与备注']) { assert.match(source, /<table class="cargo-edit-table">/)
assert.match(source, new RegExp(`<h4>${heading}</h4>`)) assert.match(source, /上传 Excel[\s\S]*下载模板[\s\S]*添加行[\s\S]*删除所选行/)
for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) {
assert.ok(cargoTableSource.includes(heading))
} }
assert.match(source, /参考运费/) assert.match(source, /参考运费/)
assert.match(source, /仅供参考,最终以实际结算为准/) assert.match(source, /仅供参考,最终以实际结算为准/)
@@ -386,8 +394,11 @@ test('shows field-level validation and concise guidance for required order input
assert.match(source, /prop="consigneePartySnapshot\.address" required/) assert.match(source, /prop="consigneePartySnapshot\.address" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/) assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/) assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
assert.match(source, /label="UPC NO\."[^>]*:required="form\.destinationType === 'FBA'"[^>]*>\s*<el-input v-model="item\.upc"/) assert.match(source, /:prop="`items\.\$\{index\}\.upc`">\s*<el-input v-model="item\.upc"[^>]*placeholder="UPC NO\.(选填)"/)
assert.match(source, /placeholder="例如:蓝牙音箱"/) assert.doesNotMatch(source, /:prop="`items\.\$\{index\}\.upc`"[^>]*(?:required|:rules)/)
assert.match(source, /UPC NO\. 选填/)
assert.doesNotMatch(source, /UPC必填/)
assert.match(source, /v-model="item\.cargoNameCn"[^>]*placeholder="中文品名"/)
assert.match(source, /\.order-form :deep\(\.el-form-item__error\).*position: static/) assert.match(source, /\.order-form :deep\(\.el-form-item__error\).*position: static/)
}) })
@@ -418,8 +429,8 @@ test('shows the full customer-safe order detail without platform-only controls',
for (const field of [ for (const field of [
'贸易条款', '发货方式', '报关方式', '运输渠道', '收货要求', '目的仓类型', '海关查验', '贸易条款', '发货方式', '报关方式', '运输渠道', '收货要求', '目的仓类型', '海关查验',
'提交时间', '完成时间', 'SKU NO.', 'UPC NO.', '报关 HS CODE', '清关 HS CODE', '每箱数量', '提交时间', '完成时间', 'SKU NO.', 'UPC NO.', '报关 HS CODE', '清关 HS CODE', '每箱数量',
'总净重', '清关申报单价', '中英文材质', '中英文用途', '产品规格', 'Shipment ID', '总净重', '清关申报单价', '中英文材质', '中英文用途', '产品规格', '货件编号',
'Reference ID', '危险品', '跟踪编号', '危险品',
]) { ]) {
assert.match(source, new RegExp(field)) assert.match(source, new RegExp(field))
} }
@@ -450,6 +461,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /<InboundBillingPanel/) assert.match(source, /<InboundBillingPanel/)
assert.match(source, /@retry="retryInboundBilling"/) assert.match(source, /@retry="retryInboundBilling"/)
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/) assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /inboundBillingUnavailableMessage\.value = error\.message \|\| '入库及费用结果尚未发布'/)
assert.match(source, /order\.status === 'DRAFT'/) assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /订单提交后可查看入库结果/) assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/) assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
@@ -492,6 +504,45 @@ test('copies reactive order drafts into plain request data', () => {
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/) assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
}) })
test('opens the server-owned inbound-code association from desktop, mobile, and order detail', () => {
assert.match(source, /import InboundCodeAssociationDialog/)
assert.match(source, /function openInboundCodeAssociation\(inboundCode: string\)/)
assert.match(source, /#inboundCode[\s\S]*?openInboundCodeAssociation\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\)/)
assert.match(source, /class="order-reference inbound-code-link"[\s\S]*?openInboundCodeAssociation\(order\.businessSummary\.inboundCode\)/)
assert.match(source, /<dt>入仓码<\/dt><dd><button[\s\S]*?openInboundCodeAssociation\(detailOrder\.businessSummary\.inboundCode\)/)
assert.match(source, /<InboundCodeAssociationDialog[\s\S]*?:inbound-code="inboundAssociationCode"/)
assert.doesNotMatch(source, /openInboundCodeAssociation\([^)]*\.id\)/)
})
test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => {
assert.match(source, /order\.status !== 'LOADED'/)
assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/)
assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/)
assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/)
assert.match(source, /class="mobile-order-card"[\s\S]*?v-if="eligibleCustomsDetail\(order\)"[\s\S]*?openCustomsDocuments\(order\)[\s\S]*?报关资料/)
assert.match(source, /<CustomsDocumentsDialog[\s\S]*?:can-edit="auth\.hasPermission\('order:first-leg:edit'\)"/)
assert.doesNotMatch(customsApiSource, /customs\/cases\//)
})
test('supports the frozen customs document workflow and customer-safe downloads', () => {
for (const path of [
'/merchant/v1/customs/orders/',
'/merchant/v1/customs/loading-plans/',
'/merchant/v1/customs/documents/',
'/inbounds',
'/completion/download',
]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/')))
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/)
assert.match(customsApiSource, /requestId: error\.requestId/)
assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/)
assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/)
assert.match(customsDialogSource, /资料版本历史/)
assert.match(customsDialogSource, /尚未上传退税报关资料/)
assert.match(customsDialogSource, /退税报关资料加载失败/)
assert.doesNotMatch(customsDialogSource, /accept=/)
})
test('uses the global inbound-code policy to require old-system codes during parallel running', () => { test('uses the global inbound-code policy to require old-system codes during parallel running', () => {
assert.match(source, /getMerchantInboundCodePolicy\(\)/) assert.match(source, /getMerchantInboundCodePolicy\(\)/)
assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/) assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/)
@@ -2,16 +2,18 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus'
import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue' import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared' import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { ApiError } from '@/api/http' import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue' import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling' import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs' import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import { import {
estimateMerchantFirstLegPrice, estimateMerchantFirstLegPrice,
downloadMerchantOrderCartonLabelsBatch, downloadMerchantOrderCartonLabelsBatch,
@@ -95,7 +97,7 @@ const cargoTemplateColumns = [
const cargoNumberKeys = new Set<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm']) const cargoNumberKeys = new Set<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
const fixedOrderColumns: DataTableColumn[] = [ const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, { key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
{ key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' }, { key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' },
] ]
const selectableOrderColumns: DataTableColumn[] = [ const selectableOrderColumns: DataTableColumn[] = [
@@ -188,9 +190,12 @@ const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false) const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布') const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingError = ref('') const inboundBillingError = ref('')
const customsDetail = ref<MerchantCustomsCaseDetail | null>(null) const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsLoading = ref(false) const customsEligibilityPending = new Set<string>()
const customsUnavailable = ref(false) const customsDialogOpen = ref(false)
const customsDialogOrderId = ref('')
const inboundAssociationOpen = ref(false)
const inboundAssociationCode = ref('')
const supportingDataLoading = ref(false) const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([]) const supportingDataErrors = ref<string[]>([])
const destinationWarehousesLoading = ref(false) const destinationWarehousesLoading = ref(false)
@@ -333,7 +338,7 @@ const packageTypeLabels: Readonly<Record<string, string>> = {
} }
function displayValue(value: string | null | undefined) { function displayValue(value: string | null | undefined) {
return value?.trim() || '-' return value?.trim() || '—'
} }
function emptyDraft(): FirstLegOrderDraft { function emptyDraft(): FirstLegOrderDraft {
@@ -461,6 +466,48 @@ const pagedOrders = computed(() => {
return filteredOrders.value.slice(start, start + tablePageSize.value) return filteredOrders.value.slice(start, start + tablePageSize.value)
}) })
function eligibleCustomsDetail(order: FirstLegOrder) {
const value = customsByOrder[order.id]
if (order.status !== 'LOADED' || !value?.merchantStatus) return null
return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null
}
async function loadCustomsEligibility(order: FirstLegOrder) {
if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return
customsEligibilityPending.add(order.id)
try {
customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id)
} catch (error) {
customsByOrder[order.id] = null
if (!isMerchantCustomsUnavailable(error)) {
// Keep the operation hidden when eligibility cannot be proven by the customer-scoped server response.
}
} finally {
customsEligibilityPending.delete(order.id)
}
}
watch(() => pagedOrders.value.map((order) => `${order.id}:${order.status}`).join('|'), () => {
pagedOrders.value.forEach((order) => void loadCustomsEligibility(order))
}, { immediate: true })
function openCustomsDocuments(order: FirstLegOrder) {
if (!eligibleCustomsDetail(order)) return
customsDialogOrderId.value = order.id
customsDialogOpen.value = true
}
function openInboundCodeAssociation(inboundCode: string) {
const code = inboundCode.trim()
if (!code) return
inboundAssociationCode.value = code
inboundAssociationOpen.value = true
}
function updateCustomsDetail(value: MerchantCustomsContainer) {
if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value
}
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined) const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null) const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const pagedDetailCargoItems = computed(() => { const pagedDetailCargoItems = computed(() => {
@@ -485,8 +532,7 @@ const progressSummaryItems = computed(() => {
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') }, { label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' }, { label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '拣货进度', value: pickingProgress }, { label: '拣货进度', value: pickingProgress },
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' }, { label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '—' },
{ label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' },
{ label: '船期摘要', value: transportReference || '待更新' }, { label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') }, { label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') }, { label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
@@ -598,9 +644,6 @@ watch(isCreatePage, (isCreate, wasCreate) => {
watch(detailOpen, (open) => { watch(detailOpen, (open) => {
if (!open) { if (!open) {
resetInboundBillingState() resetInboundBillingState()
customsDetail.value = null
customsLoading.value = false
customsUnavailable.value = false
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items) if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
} }
}) })
@@ -1073,7 +1116,6 @@ async function openDetail(order: FirstLegOrder) {
detailLoading.value = true detailLoading.value = true
detailError.value = '' detailError.value = ''
void loadInboundBilling(order) void loadInboundBilling(order)
void loadCustoms(order)
try { try {
const detailedOrder = await store.loadDetail(order.id) const detailedOrder = await store.loadDetail(order.id)
// Load previews from the persisted file IDs after the store has replaced the detail row. // Load previews from the persisted file IDs after the store has replaced the detail row.
@@ -1129,7 +1171,7 @@ async function loadInboundBilling(order: FirstLegOrder) {
if (!isCurrentInboundBillingRequest(sequence, order.id)) return if (!isCurrentInboundBillingRequest(sequence, order.id)) return
if (error instanceof ApiError && error.code === 'A0404') { if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布' inboundBillingUnavailableMessage.value = error.message || '入库及费用结果尚未发布'
} else { } else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败' inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
} }
@@ -1138,21 +1180,6 @@ async function loadInboundBilling(order: FirstLegOrder) {
} }
} }
async function loadCustoms(order: FirstLegOrder) {
customsDetail.value = null
customsUnavailable.value = false
if (order.status === 'DRAFT') return
customsLoading.value = true
try {
customsDetail.value = await getMerchantCustomsByOrder(order.id)
} catch (error) {
if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true
else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败')
} finally {
customsLoading.value = false
}
}
async function retryInboundBilling() { async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order) if (order) await loadInboundBilling(order)
@@ -1203,7 +1230,7 @@ function removeCargoItem(index: number) {
} }
function displayListValue(value: string | number | null | undefined) { function displayListValue(value: string | number | null | undefined) {
return value === '' || value == null ? '-' : String(value) return value === '' || value == null ? '—' : String(value)
} }
function orderMatchesCargoKeyword(order: FirstLegOrder, keyword: string) { function orderMatchesCargoKeyword(order: FirstLegOrder, keyword: string) {
@@ -1632,12 +1659,22 @@ async function searchOrders(model: QueryModel) {
<div class="order-row-actions"> <div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button> <el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button> <el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
</div> </div>
</template> </template>
<template #order="{ row }"> <template #order="{ row }">
<button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '未提交草稿'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button> <button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '—'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '—' }}</button>
</template>
<template #inboundCode="{ row }">
<button
v-if="(row as FirstLegOrder).businessSummary.inboundCode"
class="inbound-code-link table-ellipsis table-code"
type="button"
:title="`查看入仓码 ${(row as FirstLegOrder).businessSummary.inboundCode} 的关联摘要`"
@click="openInboundCodeAssociation((row as FirstLegOrder).businessSummary.inboundCode)"
>{{ (row as FirstLegOrder).businessSummary.inboundCode }}</button>
<span v-else>—</span>
</template> </template>
<template #inboundCode="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.inboundCode)">{{ displayListValue((row as FirstLegOrder).businessSummary.inboundCode) }}</span></template>
<template #channel="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).channelName)">{{ displayListValue((row as FirstLegOrder).channelName) }}</span></template> <template #channel="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).channelName)">{{ displayListValue((row as FirstLegOrder).channelName) }}</span></template>
<template #transportMode="{ row }">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : '-' }}</template> <template #transportMode="{ row }">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : '-' }}</template>
<template #pickupMethod="{ row }">{{ pickupMethodLabels[(row as FirstLegOrder).pickupMethod] }}</template> <template #pickupMethod="{ row }">{{ pickupMethodLabels[(row as FirstLegOrder).pickupMethod] }}</template>
@@ -1677,11 +1714,19 @@ async function searchOrders(model: QueryModel) {
<div v-loading="store.loading" class="mobile-order-surface"> <div v-loading="store.loading" class="mobile-order-surface">
<div v-if="filteredOrders.length" class="mobile-order-list"> <div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card"> <article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header> <header><button type="button" @click="openDetail(order)">{{ order.orderNo || '—' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<span v-if="order.businessSummary.inboundCode" class="order-reference">入仓码 {{ order.businessSummary.inboundCode }}</span> <button v-if="order.businessSummary.inboundCode" class="order-reference inbound-code-link" type="button" @click="openInboundCodeAssociation(order.businessSummary.inboundCode)">入仓码 {{ order.businessSummary.inboundCode }}</button>
<span class="order-reference">{{ order.referenceNo || '无 Reference ID' }}</span> <span v-else class="order-reference">入仓码 —</span>
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[order.destinationType] }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '待更新' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输方式</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}</dd></div><div><dt>交货方式</dt><dd>{{ pickupMethodLabels[order.pickupMethod] }}</dd></div><div><dt>预约提货时间</dt><dd>{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '-' }}</dd></div><div v-if="order.businessSummary.cabinetNo || order.businessSummary.containerNo"><dt>柜号 / 柜次</dt><dd>{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo }}</dd></div><div v-if="order.businessSummary.vesselName || order.businessSummary.voyageNo"><dt>船名 / 航次</dt><dd>{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}</dd></div><div v-if="order.businessSummary.sailingAt"><dt>预计船期</dt><dd>{{ formatDateTime(order.businessSummary.sailingAt) }}</dd></div></dl> <span class="order-reference">{{ order.referenceNo || '跟踪编号 —' }}</span>
<footer><span>更新于 {{ formatDateTime(order.updatedAt) }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer> <dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[order.destinationType] }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '—' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输方式</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}</dd></div><div><dt>交货方式</dt><dd>{{ pickupMethodLabels[order.pickupMethod] }}</dd></div><div><dt>预约提货时间</dt><dd>{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '—' }}</dd></div><div><dt>柜号 / 柜次</dt><dd>{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo || '—' }}</dd></div><div v-if="order.businessSummary.vesselName || order.businessSummary.voyageNo"><dt>船名 / 航次</dt><dd>{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}</dd></div><div v-if="order.businessSummary.sailingAt"><dt>预计船期</dt><dd>{{ formatDateTime(order.businessSummary.sailingAt) }}</dd></div></dl>
<footer>
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
<div>
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
</div>
</footer>
</article> </article>
</div> </div>
<el-empty v-else :description="emptyDescription"> <el-empty v-else :description="emptyDescription">
@@ -1772,7 +1817,7 @@ async function searchOrders(model: QueryModel) {
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th> <th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th> <th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column">总毛重</th><th class="cargo-number-column">总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th> <th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column">总毛重</th><th class="cargo-number-column">总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>材质</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.<small>UPC必填</small></th><th>备注</th><th class="cargo-operation-column">操作</th> <th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>材质</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead> </tr></thead>
<tbody><tr v-for="(item, index) in form.items" :key="item.id"> <tbody><tr v-for="(item, index) in form.items" :key="item.id">
<td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td> <td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td>
@@ -1785,11 +1830,11 @@ async function searchOrders(model: QueryModel) {
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td> <td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td> <td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td> <td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入 UPC NO.') : undefined"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO." /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td> <td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody> </tr></tbody>
</table> </table>
</div> </div>
<div class="cargo-table-hint"><span>SKU NO.、货件编号、跟踪编号、UPC NO. 均为必填项</span></div> <div class="cargo-table-hint"><span>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
</section> </section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估"> <section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
@@ -1839,13 +1884,13 @@ async function searchOrders(model: QueryModel) {
</div> </div>
<template v-else-if="detailOrder"> <template v-else-if="detailOrder">
<div class="order-detail-heading"> <div class="order-detail-heading">
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div> <div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '—' }}</h2><p>{{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag> <el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
</div> </div>
<section class="detail-section"> <section class="detail-section">
<h3>订单与运输</h3> <h3>订单与运输</h3>
<dl class="order-detail-grid"> <dl class="order-detail-grid">
<div v-if="detailOrder.businessSummary.inboundCode"><dt>入仓码</dt><dd>{{ detailOrder.businessSummary.inboundCode }}</dd></div> <div><dt>入仓码</dt><dd><button v-if="detailOrder.businessSummary.inboundCode" class="inbound-code-link table-code" type="button" @click="openInboundCodeAssociation(detailOrder.businessSummary.inboundCode)">{{ detailOrder.businessSummary.inboundCode }}</button><span v-else>—</span></dd></div>
<div><dt>创建时间</dt><dd>{{ formatDateTime(detailOrder.createdAt) }}</dd></div> <div><dt>创建时间</dt><dd>{{ formatDateTime(detailOrder.createdAt) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div> <div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ displayValue(detailOrder.tradeTerm) }}</dd></div> <div><dt>贸易条款</dt><dd>{{ displayValue(detailOrder.tradeTerm) }}</dd></div>
@@ -1853,7 +1898,7 @@ async function searchOrders(model: QueryModel) {
<div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div> <div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div>
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div> <div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div> <div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div>
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '-' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '仓库编码待更新' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div> <div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '—' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '—' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div> <div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div> <div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div> <div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
@@ -1882,9 +1927,9 @@ async function searchOrders(model: QueryModel) {
<h3>柜次进度</h3> <h3>柜次进度</h3>
<div v-if="cabinetProgressItems.length" class="cabinet-progress-list"> <div v-if="cabinetProgressItems.length" class="cabinet-progress-list">
<article v-for="cabinet in cabinetProgressItems" :key="cabinet.cabinetId" class="cabinet-progress-card"> <article v-for="cabinet in cabinetProgressItems" :key="cabinet.cabinetId" class="cabinet-progress-card">
<header><strong>{{ cabinet.cabinetNo || '柜次待分配' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header> <header><strong>{{ cabinet.cabinetNo || '—' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
<dl> <dl>
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '待分配' }}</dd></div> <div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '—' }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</dd></div> <div><dt>船名 / 航次</dt><dd>{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</dd></div>
<div><dt>预计船期</dt><dd>{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div> <div><dt>预计船期</dt><dd>{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div>
</dl> </dl>
@@ -1892,16 +1937,6 @@ async function searchOrders(model: QueryModel) {
</div> </div>
<el-empty v-else description="暂无柜次分配信息" :image-size="50" /> <el-empty v-else description="暂无柜次分配信息" :image-size="50" />
</section> </section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>报关回执</h3>
<el-skeleton v-if="customsLoading" :rows="2" animated />
<el-empty v-else-if="customsUnavailable || !customsDetail?.receipts.length" description="暂无客户可见报关回执" :image-size="50" />
<div v-else class="customs-receipt-list">
<article v-for="receipt in customsDetail.receipts" :key="receipt.id">
<strong>{{ receipt.receiptType }}</strong><span>版本 {{ receipt.receiptVersion ?? '-' }}</span><span>{{ receipt.uploadedAt ? formatDateTime(receipt.uploadedAt) : '时间待更新' }}</span>
</article>
</div>
</section>
<section class="detail-section"> <section class="detail-section">
<h3>履约快递信息</h3> <h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div> <div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
@@ -1964,6 +1999,17 @@ async function searchOrders(model: QueryModel) {
</div> </div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template> <template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-dialog> </el-dialog>
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
:can-edit="auth.hasPermission('order:first-leg:edit')"
:initial-detail="customsByOrder[customsDialogOrderId]"
@updated="updateCustomsDetail"
/>
<InboundCodeAssociationDialog
v-model="inboundAssociationOpen"
:inbound-code="inboundAssociationCode"
/>
</section> </section>
</template> </template>
@@ -2040,6 +2086,9 @@ async function searchOrders(model: QueryModel) {
.mobile-order-surface { display: none; min-height: 220px; } .mobile-order-surface { display: none; min-height: 220px; }
.mobile-order-list { display: none; } .mobile-order-list { display: none; }
.order-number-link { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-size: 11px; font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; } .order-number-link { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-size: 11px; font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.inbound-code-link { max-width: 100%; overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; text-align: left; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px; text-overflow: ellipsis; white-space: nowrap; }
.inbound-code-link:hover, .inbound-code-link:focus-visible { text-decoration-color: currentColor; }
.inbound-code-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } .order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; } .order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
.order-row-actions { align-items: center; justify-content: center; gap: 6px; white-space: nowrap; } .order-row-actions { align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
@@ -2226,9 +2275,6 @@ async function searchOrders(model: QueryModel) {
.cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; } .cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; }
.cabinet-progress-card dt { color: var(--muted); font-size: 10px; } .cabinet-progress-card dt { color: var(--muted); font-size: 10px; }
.cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; } .cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; }
.customs-receipt-list { display: grid; gap: 8px; }
.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; }
.customs-receipt-list strong { color: var(--navy); }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); } .express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } .express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; } .express-information span { color: var(--muted); font-size: 11px; }
@@ -78,7 +78,10 @@ test('requires the old inbound code globally only while legacy manual mode is ac
test('shows every created draft and keeps failed rows available for retry', () => { test('shows every created draft and keeps failed rows available for retry', () => {
assert.match(source, /v-for="result in draftResults"/) assert.match(source, /v-for="result in draftResults"/)
assert.match(source, /入仓码将在提交订单时生成/) assert.match(source, /result\.orderNo \|\| '订单号 —'/)
assert.match(source, /result\.inboundCode \? `入仓码 \$\{result\.inboundCode\}` : '入仓码 —'/)
assert.doesNotMatch(source, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
assert.doesNotMatch(source, /item\.orderNo \|\| item\.draftId/)
assert.match(source, /@click="editDraft\(result\)"/) assert.match(source, /@click="editDraft\(result\)"/)
assert.match(source, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/) assert.match(source, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/)
assert.match(source, /resultDialogOpen\.value = successes\.length > 0/) assert.match(source, /resultDialogOpen\.value = successes\.length > 0/)
@@ -394,8 +394,10 @@ async function submitQuickOrder() {
await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId))) await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId)))
workspace.close(['first-leg-quick-order']) workspace.close(['first-leg-quick-order'])
await router.push({ name: 'first-leg-orders' }) await router.push({ name: 'first-leg-orders' })
const orderNumbers = successes.map((item) => item.orderNo || item.draftId).join('、') const orderNumbers = successes.map((item) => item.orderNo).filter(Boolean).join('、')
ElMessage.success(`已生成 ${successes.length} 张草稿单:${orderNumbers}`) ElMessage.success(orderNumbers
? `已生成 ${successes.length} 张草稿单:${orderNumbers}`
: `已生成 ${successes.length} 张草稿单,订单号 —`)
} }
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败') ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败')
@@ -529,7 +531,7 @@ onMounted(loadChannels)
<el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)"> <el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)">
<div class="quick-draft-results"> <div class="quick-draft-results">
<article v-for="result in draftResults" :key="result.rowId"> <article v-for="result in draftResults" :key="result.rowId">
<div><strong>{{ result.orderNo || `草稿 ${result.draftId}` }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码将在提交订单时生成' }}</span></div> <div><strong>{{ result.orderNo || '订单号 —' }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码 —' }}</span></div>
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button> <el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
</article> </article>
</div> </div>
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const firstLeg = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
const quickOrder = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'utf8')
const customs = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const overseas = readFileSync(new URL('../../overseas-warehouse/views/WarehouseServiceView.vue', import.meta.url), 'utf8')
test('uses business identifiers and names without visible internal-ID fallbacks', () => {
assert.match(firstLeg, /label: '入仓码'/)
assert.match(firstLeg, /label: '订单号'/)
assert.match(firstLeg, /label: '柜号 \/ 柜次'/)
assert.match(firstLeg, /label: '国内收货仓'/)
assert.doesNotMatch(quickOrder, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
assert.doesNotMatch(quickOrder, /item\.orderNo \|\| item\.draftId/)
assert.doesNotMatch(customs, /detail\.planNo \|\| detail\.loadingPlanId/)
assert.doesNotMatch(overseas, /overseasWarehouseName \|\| (?:item|candidate)\.overseasWarehouseId/)
assert.doesNotMatch(overseas, /row\(\[[^\]]*(?:item\.overseasWarehouseId|item\.operatingWarehouseId)/)
})
test('keeps internal IDs only as non-rendered routing, selection, and request values', () => {
assert.match(quickOrder, /params: \{ draftId: result\.draftId \}/)
assert.match(customs, /uploadMerchantCustomsDocument\(detail\.value\.loadingPlanId/)
assert.match(overseas, /:value="candidate\.orderId"/)
assert.match(firstLeg, /detailId\.value = order\.id/)
})
@@ -44,4 +44,15 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()])
<style scoped> <style scoped>
.notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}} .notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}}
/* Keep the notification filters compact and aligned with the other client lists. */
.filter-bar { align-items: flex-end; }
.filter-bar > .el-select { width: 190px; }
.filter-bar > .el-input { width: 260px; }
.filter-bar > .el-button { flex: 0 0 auto; min-height: 32px; }
.filter-bar > .el-button + .el-button { margin-left: 0; }
@media (max-width: 720px) {
.filter-bar { align-items: stretch; flex-direction: column; }
.filter-bar > .el-select, .filter-bar > .el-input { width: 100%; }
.filter-bar > .el-button { width: 100%; }
}
</style> </style>
@@ -69,21 +69,21 @@ function selectDiscardInbound(orderId: string) {
})) }))
} }
function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined } function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined }
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '-' : String(value)])) } function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) }
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
if (props.service === 'labeling') { if (props.service === 'labeling') {
const result = await listRelabels({ page: 1, size: 20, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) const result = await listRelabels({ page: 1, size: 20, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, item.overseasWarehouseId, '-', item.itemCount, item.totalPackageCount, item.status, item.createdAt])) submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, '—', '—', item.itemCount, item.totalPackageCount, item.status, item.createdAt]))
} else if (props.service === 'disposal') { } else if (props.service === 'disposal') {
const result = await listDiscards({ page: 1, size: 20, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) const result = await listDiscards({ page: 1, size: 20, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || item.overseasWarehouseId, '-', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt])) submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt]))
} else { } else {
const result = await listTransfers({ page: 1, size: 20, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, needRelabel: props.service === 'relabel', createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) const result = await listTransfers({ page: 1, size: 20, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, needRelabel: props.service === 'relabel', createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => props.service === 'transfer' submittedRows.value = result.records.map((item) => props.service === 'transfer'
? row([item.transferNo, item.inboundCode, item.packageCount, item.operatingWarehouseId, item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt]) ? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt])
: row([item.transferNo, item.operatingWarehouseId, item.fbaWarehouseCode, '-', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt])) : row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt]))
} }
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '列表加载失败') } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
finally { loading.value = false } finally { loading.value = false }
@@ -169,7 +169,7 @@ async function openDetail(value: Record<string, string>) {
<div class="warehouse-card warehouse-form-section"><h3>{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}</h3> <div class="warehouse-card warehouse-form-section"><h3>{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}</h3>
<div class="warehouse-form-grid"> <div class="warehouse-form-grid">
<label v-if="props.service === 'labeling'"><span><i>*</i>客户单号</span><el-input v-model="form.customerNo" placeholder="请输入客户单号" /></label> <label v-if="props.service === 'labeling'"><span><i>*</i>客户单号</span><el-input v-model="form.customerNo" placeholder="请输入客户单号" /></label>
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode}|${candidate.inboundCode}|${candidate.overseasWarehouseName || candidate.overseasWarehouseId}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label> <label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|${candidate.overseasWarehouseName || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label> <label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" placeholder="请选择"><el-option label="ONT8" value="ONT8" /><el-option label="ABE8" value="ABE8" /><el-option label="CLT2" value="CLT2" /></el-select></label> <label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" placeholder="请选择"><el-option label="ONT8" value="ONT8" /><el-option label="ABE8" value="ABE8" /><el-option label="CLT2" value="CLT2" /></el-select></label>
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label> <label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
@@ -198,7 +198,7 @@ async function openDetail(value: Record<string, string>) {
</form> </form>
</template> </template>
<template v-else><div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div><div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '-' }}</strong></div></div></div></template> <template v-else><div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div><div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div></template>
</section> </section>
</template> </template>
@@ -107,3 +107,12 @@ test('keeps client baseline permissions selected when server resources do not de
assert.match(permissionsViewSource, /:disabled="isRequiredPermission\(item\.code\)"/) assert.match(permissionsViewSource, /:disabled="isRequiredPermission\(item\.code\)"/)
assert.doesNotMatch(permissionsViewSource, /selectedCodes\.value\.unshift\('account:profile:view'\)/) assert.doesNotMatch(permissionsViewSource, /selectedCodes\.value\.unshift\('account:profile:view'\)/)
}) })
test('exposes all message-center permissions in the enterprise role editor', () => {
for (const permission of ['merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all']) {
assert.match(contractsSource, new RegExp(`code: '${permission}'`))
assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
}
assert.match(contractsSource, /key: 'notifications'/)
assert.match(storeSource, /const mappedMerchantPermissionCodes = new Set\(Object\.values\(FRONTEND_TO_MERCHANT_PERMISSION_CODES\)\.flat\(\)\)/)
})
@@ -25,6 +25,15 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
label: '业务总览', label: '业务总览',
items: [{ code: 'dashboard:view', label: '查看总览', description: '所有企业成员默认可用,可查看企业统计和最近订单' }], items: [{ code: 'dashboard:view', label: '查看总览', description: '所有企业成员默认可用,可查看企业统计和最近订单' }],
}, },
{
key: 'notifications',
label: '消息中心',
items: [
{ code: 'merchant:notification:view', label: '查看我的消息', description: '查看本企业和当前成员可见的站内消息' },
{ code: 'merchant:notification:read', label: '标记消息已读', description: '将已查看的站内消息标记为已读' },
{ code: 'merchant:notification:read-all', label: '全部标记已读', description: '将当前成员的全部站内消息标记为已读' },
],
},
{ {
key: 'finance', key: 'finance',
label: '财务账单', label: '财务账单',
@@ -200,7 +200,7 @@ async function resetPassword(member: TenantMember) {
<el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item> <el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
<el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item> <el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
<div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div> <div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item> <el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE' && (!item.builtin || form.roleIds.includes(item.id)))" :key="role.id" :label="role.name" :value="role.id" :disabled="role.builtin" /></el-select></el-form-item>
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div> <div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
</el-form> </el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template> <template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
+3 -3
View File
@@ -26,12 +26,12 @@ test('style source contract: constrains the desktop sidebar and gives the menu i
test('style source contract: presents native grouped rows matching the prototype active and divider states', () => { test('style source contract: presents native grouped rows matching the prototype active and divider states', () => {
assert.doesNotMatch(compact, /\.client-sidebar \.el-menu|\.el-menu-item|\.el-sub-menu/) assert.doesNotMatch(compact, /\.client-sidebar \.el-menu|\.el-menu-item|\.el-sub-menu/)
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*margin:\s*0[^}]*padding:\s*0[^}]*list-style:\s*none/) assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*margin:\s*0[^}]*padding:\s*0[^}]*list-style:\s*none/)
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px[^}]*border-radius:\s*10px[^}]*color:\s*rgba\(255, 255, 255, \.72\)/) assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*46px[^}]*border-radius:\s*10px[^}]*color:\s*rgba\(255, 255, 255, \.82\)/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*rgba\(30, 136, 229, \.25\)[^}]*color:\s*#fff/) assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*rgba\(30, 136, 229, \.25\)[^}]*color:\s*#fff/)
assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*color:\s*#fff/) assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*color:\s*#fff/)
assert.match(compact, /\.client-nav-node\.has-divider-before\s*\{[^}]*border-top:\s*1px solid rgba\(255, 255, 255, \.09\)/) assert.match(compact, /\.client-nav-node\.has-divider-before\s*\{[^}]*border-top:\s*1px solid rgba\(255, 255, 255, \.09\)/)
assert.match(compact, /\.client-nav-submenu\s*\{[^}]*padding:\s*2px 0 2px 12px/) assert.match(compact, /\.client-nav-submenu\s*\{[^}]*padding:\s*2px 0 2px 12px/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\s*\{[^}]*height:\s*40px[^}]*margin:\s*1px 0[^}]*padding:\s*0 12px[^}]*border-radius:\s*9px/) assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\s*\{[^}]*height:\s*42px[^}]*margin:\s*1px 0[^}]*padding:\s*0 14px 0 22px[^}]*border-radius:\s*9px/)
assert.match(compact, /\.client-sidebar \.client-nav-submenu \.client-sidebar-node-icon\s*\{[^}]*width:\s*16px[^}]*height:\s*16px[^}]*flex:\s*0 0 16px/) assert.match(compact, /\.client-sidebar \.client-nav-submenu \.client-sidebar-node-icon\s*\{[^}]*width:\s*16px[^}]*height:\s*16px[^}]*flex:\s*0 0 16px/)
assert.match(compact, /\.client-sidebar-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/) assert.match(compact, /\.client-sidebar-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar-link\s*\{[^}]*text-decoration:\s*none/) assert.match(compact, /\.client-sidebar-link\s*\{[^}]*text-decoration:\s*none/)
@@ -40,7 +40,7 @@ test('style source contract: presents native grouped rows matching the prototype
}) })
test('style source contract: makes navigation and drawer controls touch sized with visible keyboard focus', () => { test('style source contract: makes navigation and drawer controls touch sized with visible keyboard focus', () => {
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px/) assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*46px/)
assert.match(compact, /\.client-sidebar-group-trigger:focus-visible,\s*\.client-sidebar-link:focus-visible,[\s\S]*?\.client-sidebar-logout:focus-visible\s*\{[^}]*outline:\s*2px solid #6db9ff[^}]*outline-offset:\s*2px/) assert.match(compact, /\.client-sidebar-group-trigger:focus-visible,\s*\.client-sidebar-link:focus-visible,[\s\S]*?\.client-sidebar-logout:focus-visible\s*\{[^}]*outline:\s*2px solid #6db9ff[^}]*outline-offset:\s*2px/)
assert.match(compact, /\.client-sidebar-logout\s*\{[^}]*height:\s*44px[^}]*background:\s*rgba\(239, 68, 68, \.12\)/) assert.match(compact, /\.client-sidebar-logout\s*\{[^}]*height:\s*44px[^}]*background:\s*rgba\(239, 68, 68, \.12\)/)
}) })