Merge branch 'release/test' of http://git.buggoglobal.com/html/client-web into dev
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
||||
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
|
||||
|
||||
Generated
+8
@@ -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
|
||||
Generated
+9
@@ -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>
|
||||
Generated
+6
@@ -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>
|
||||
Generated
+8
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -47,7 +47,8 @@
|
||||
| 企业资料、成员与角色 | `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/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/finance/`;确认入口 `views/FinanceBillsView.vue`、`api/finance.ts` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 |
|
||||
| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 |
|
||||
|
||||
@@ -35,7 +35,7 @@ pnpm test
|
||||
|
||||
依赖和 `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 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
|
||||
|
||||
@@ -47,4 +47,4 @@ pnpm test
|
||||
|
||||
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 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 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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}"
|
||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: client-web-test:local
|
||||
@@ -12,8 +12,6 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-9002}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
|
||||
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
|
||||
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
|
||||
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
|
||||
|
||||
@@ -13,10 +13,22 @@ server {
|
||||
|
||||
# 前端统一请求 /api,保持原始路径转发给 Gateway。
|
||||
location /api/ {
|
||||
client_max_body_size 12m;
|
||||
client_max_body_size 55m;
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
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-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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 () => {
|
||||
let currentRefreshToken = 'refresh-old'
|
||||
@@ -10,7 +10,7 @@ test('reuses tokens rotated by another browser tab while waiting for the refresh
|
||||
configurable: true,
|
||||
value: {
|
||||
locks: {
|
||||
async request(_name, callback) {
|
||||
async request(_name, _options, callback) {
|
||||
currentRefreshToken = 'refresh-new'
|
||||
return callback()
|
||||
},
|
||||
@@ -41,7 +41,7 @@ test('refreshes normally when this tab owns the current refresh token', async ()
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { locks: { request: async (_name, callback) => callback() } },
|
||||
value: { locks: { request: async (_name, _options, callback) => callback() } },
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
lockTimeoutMs?: number
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => 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> {
|
||||
const run = async () => {
|
||||
const currentRefreshToken = options.currentRefreshToken()
|
||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
|
||||
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
@@ -3,7 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
|
||||
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
|
||||
import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh'
|
||||
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
|
||||
|
||||
const transport = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -18,7 +18,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||
if (envelope.code !== '200') {
|
||||
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
|
||||
}
|
||||
@@ -36,6 +36,7 @@ function mapError(error: unknown) {
|
||||
error.response?.status,
|
||||
fieldErrors,
|
||||
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')
|
||||
@@ -78,6 +79,16 @@ export async function refreshAuthenticatedSession() {
|
||||
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(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
@@ -132,6 +143,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) {
|
||||
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
await throwBlobApiError(error)
|
||||
throw mapError(error)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +153,28 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
|
||||
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
||||
return { blob: response.data, headers: response.headers }
|
||||
} catch (error) {
|
||||
await throwBlobApiError(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 }
|
||||
|
||||
@@ -18,6 +18,7 @@ export class ApiError extends Error {
|
||||
readonly status?: number,
|
||||
readonly fieldErrors?: ApiFieldErrors,
|
||||
readonly traceId?: string | null,
|
||||
readonly requestId?: string | null,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
|
||||
@@ -3,11 +3,23 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
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 dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
|
||||
|
||||
test('allows qualification uploads with multipart overhead through the API proxy', () => {
|
||||
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/)
|
||||
test('allows 50 MiB uploads with multipart overhead through both API proxies', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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, /envelope\.code !== '200'/)
|
||||
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(authStoreSource, /decodeMerchantToken/)
|
||||
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/)
|
||||
})
|
||||
@@ -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 {
|
||||
id: number | string
|
||||
receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string
|
||||
receiptVersion: number | null
|
||||
fileName: string | null
|
||||
uploadedAt: string | null
|
||||
export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED'
|
||||
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
|
||||
|
||||
export interface MerchantCustomsFile {
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string
|
||||
contentType: string | null
|
||||
sizeBytes: number
|
||||
version: number
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
export interface MerchantCustomsCaseDetail {
|
||||
customsCase: {
|
||||
id: number | string
|
||||
customsNo: string | null
|
||||
orderId: number | string
|
||||
status: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
receipts: MerchantCustomsReceipt[]
|
||||
export interface MerchantCustomsInbound {
|
||||
inboundId: string
|
||||
orderId: string
|
||||
orderNo: string
|
||||
inboundCode: string
|
||||
tenantId: string
|
||||
customerName: string
|
||||
taxMode: 'TAX_REFUND' | 'BUY_DECLARATION'
|
||||
covered: boolean
|
||||
}
|
||||
|
||||
export function getMerchantCustomsByOrder(orderId: number | string) {
|
||||
return apiRequest<MerchantCustomsCaseDetail>({
|
||||
export interface MerchantCustomsDocument {
|
||||
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',
|
||||
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) {
|
||||
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ export interface MerchantFirstLegOrderRow {
|
||||
inboundCode: string | null
|
||||
channelName: string | null
|
||||
destinationType: ApiDestinationType | null
|
||||
pickupMethod: ApiPickupMethod | null
|
||||
customerName: string | null
|
||||
receivingCompanyName: 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, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
||||
assert.match(source, /getRoundingModeLabel\(props\.data\.latestFee\.roundingMode\)/)
|
||||
assert.match(source, /formatConversionPrecision\(/)
|
||||
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
|
||||
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
|
||||
assert.match(source, /latestFee\.ruleCode/)
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||
import {
|
||||
formatConversionPrecision,
|
||||
formatInboundDateTime,
|
||||
formatInboundMeasure,
|
||||
formatInboundMoney,
|
||||
formatInboundNumber,
|
||||
getChargeModeLabel,
|
||||
getRoundingModeLabel,
|
||||
getTaxModeLabel,
|
||||
} 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>
|
||||
系数 {{ formatInboundNumber(props.data.latestFee.conversionFactor) }} ·
|
||||
保留 {{ props.data.latestFee.roundingScale }} 位 {{ getRoundingModeLabel(props.data.latestFee.roundingMode) }}
|
||||
</dd>
|
||||
<dd>{{ formatConversionPrecision(
|
||||
props.data.latestFee.conversionFactor,
|
||||
props.data.latestFee.roundingScale,
|
||||
props.data.latestFee.roundingMode,
|
||||
) }}</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>
|
||||
|
||||
@@ -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, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
|
||||
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, /row\.skuNos/)
|
||||
assert.match(storeSource, /row\.cargoes/)
|
||||
assert.match(storeSource, /chargeableWeightKg: 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\(\(item\) => mapUnifiedOrderRow\(item, warehouses\.value\)\)/)
|
||||
})
|
||||
|
||||
@@ -398,7 +398,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
|
||||
inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '',
|
||||
},
|
||||
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',
|
||||
customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '',
|
||||
shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', requestedPickupAt: '', receivingTime: '', submittedAt: '', completedAt: '',
|
||||
|
||||
@@ -79,9 +79,9 @@ export interface MerchantInboundFeeSnapshot {
|
||||
destinationCode: string
|
||||
taxMode: string
|
||||
chargeMode: 'KG' | 'CBM'
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
conversionFactor: number | null
|
||||
roundingScale: number | null
|
||||
roundingMode: string | null
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
convertedQuantity: number
|
||||
|
||||
@@ -108,7 +108,7 @@ test('only requires sender for pickup and receiver for a private destination', (
|
||||
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()
|
||||
draft.referenceNo = ''
|
||||
draft.amazonWarehouseCode = ''
|
||||
@@ -121,7 +121,7 @@ test('requires FBA warehouse and cargo-level shipment, reference, SKU and UPC fi
|
||||
const errors = validateFirstLegOrderDraft(draft)
|
||||
assert.ok(errors.includes('FBA 订单请填写 Amazon 仓库编码'))
|
||||
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 条货物缺少跟踪编号'))
|
||||
})
|
||||
|
||||
@@ -122,7 +122,6 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
|
||||
if (!item.cargoNameCn.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.upc.trim()) errors.push(`${line}缺少 UPC`)
|
||||
if (draft.destinationType === 'FBA' && !item.shipmentId.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}件数必须为正整数`)
|
||||
|
||||
@@ -16,5 +16,12 @@ test('translates backend rounding modes into customer-facing calculation labels'
|
||||
assert.match(source, /export function getRoundingModeLabel/)
|
||||
assert.match(source, /CEILING: '向上取整'/)
|
||||
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\)\}`/)
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ export function getTaxModeLabel(value: string) {
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
export function getRoundingModeLabel(value: string) {
|
||||
export function getRoundingModeLabel(value: string | null | undefined) {
|
||||
const labels: Record<string, string> = {
|
||||
UP: '向上舍入',
|
||||
DOWN: '向下舍入',
|
||||
@@ -56,5 +56,16 @@ export function getRoundingModeLabel(value: string) {
|
||||
HALF_DOWN: '五舍六入',
|
||||
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 editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.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', () => {
|
||||
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, /async function openDetail\(order: FirstLegOrder\)/)
|
||||
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, /tenantStore\.loadCompanyContext\(\)/)
|
||||
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 companySubmissionBlocked = computed\(\(\) => companyAuditLoaded\.value/)
|
||||
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', () => {
|
||||
@@ -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, /await store\.load\(\{/)
|
||||
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|订单概览/)
|
||||
})
|
||||
|
||||
@@ -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, /国内装柜进度及后续履约同步/)
|
||||
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
|
||||
assert.match(source, /报关回执/)
|
||||
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
|
||||
assert.match(source, /船期摘要/)
|
||||
assert.match(source, /柜次进度/)
|
||||
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', () => {
|
||||
assert.match(source, /label: '入仓码 \/ SKU'/)
|
||||
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, /v-if="order\.businessSummary\.inboundCode"[^>]*>入仓码/)
|
||||
assert.match(source, /v-if="detailOrder\.businessSummary\.inboundCode"><dt>入仓码<\/dt>/)
|
||||
assert.match(source, /<template #inboundCode="\{ row \}">[\s\S]*?class="inbound-code-link table-ellipsis table-code"/)
|
||||
assert.match(source, /v-if="order\.businessSummary\.inboundCode" class="order-reference inbound-code-link"/)
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
const queryToolbarSource = source.slice(source.indexOf('<QueryToolbar'), source.indexOf('</QueryToolbar>') + '</QueryToolbar>'.length)
|
||||
assert.match(source, /class="order-table-toolbar"/)
|
||||
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.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;/)
|
||||
})
|
||||
|
||||
@@ -316,6 +321,7 @@ test('collects the backend-required first-leg fields', () => {
|
||||
})
|
||||
|
||||
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>/)
|
||||
@@ -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.match(source, /class="segmented-options"/)
|
||||
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
|
||||
for (const heading of ['商品信息', '箱规、数量与重量', '报关与产品资料', '货件与备注']) {
|
||||
assert.match(source, new RegExp(`<h4>${heading}</h4>`))
|
||||
assert.match(source, /<table class="cargo-edit-table">/)
|
||||
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, /仅供参考,最终以实际结算为准/)
|
||||
@@ -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="`items\.\$\{index\}\.cargoNameCn`" 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, /placeholder="例如:蓝牙音箱"/)
|
||||
assert.match(source, /:prop="`items\.\$\{index\}\.upc`">\s*<el-input v-model="item\.upc"[^>]*placeholder="UPC NO\.(选填)"/)
|
||||
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/)
|
||||
})
|
||||
|
||||
@@ -418,8 +429,8 @@ test('shows the full customer-safe order detail without platform-only controls',
|
||||
for (const field of [
|
||||
'贸易条款', '发货方式', '报关方式', '运输渠道', '收货要求', '目的仓类型', '海关查验',
|
||||
'提交时间', '完成时间', 'SKU NO.', 'UPC NO.', '报关 HS CODE', '清关 HS CODE', '每箱数量',
|
||||
'总净重', '清关申报单价', '中英文材质', '中英文用途', '产品规格', 'Shipment ID',
|
||||
'Reference ID', '危险品',
|
||||
'总净重', '清关申报单价', '中英文材质', '中英文用途', '产品规格', '货件编号',
|
||||
'跟踪编号', '危险品',
|
||||
]) {
|
||||
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, /@retry="retryInboundBilling"/)
|
||||
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, /订单提交后可查看入库结果/)
|
||||
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\.)/)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
assert.match(source, /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 { useRoute, useRouter } from 'vue-router'
|
||||
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 QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import { ApiError } from '@/api/http'
|
||||
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.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 CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
|
||||
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 {
|
||||
estimateMerchantFirstLegPrice,
|
||||
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 fixedOrderColumns: DataTableColumn[] = [
|
||||
{ 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' },
|
||||
]
|
||||
const selectableOrderColumns: DataTableColumn[] = [
|
||||
@@ -188,9 +190,12 @@ const inboundBillingLoading = ref(false)
|
||||
const inboundBillingUnavailable = ref(false)
|
||||
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
|
||||
const inboundBillingError = ref('')
|
||||
const customsDetail = ref<MerchantCustomsCaseDetail | null>(null)
|
||||
const customsLoading = ref(false)
|
||||
const customsUnavailable = ref(false)
|
||||
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
|
||||
const customsEligibilityPending = new Set<string>()
|
||||
const customsDialogOpen = ref(false)
|
||||
const customsDialogOrderId = ref('')
|
||||
const inboundAssociationOpen = ref(false)
|
||||
const inboundAssociationCode = ref('')
|
||||
const supportingDataLoading = ref(false)
|
||||
const supportingDataErrors = ref<string[]>([])
|
||||
const destinationWarehousesLoading = ref(false)
|
||||
@@ -333,7 +338,7 @@ const packageTypeLabels: Readonly<Record<string, string>> = {
|
||||
}
|
||||
|
||||
function displayValue(value: string | null | undefined) {
|
||||
return value?.trim() || '-'
|
||||
return value?.trim() || '—'
|
||||
}
|
||||
|
||||
function emptyDraft(): FirstLegOrderDraft {
|
||||
@@ -461,6 +466,48 @@ const pagedOrders = computed(() => {
|
||||
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 detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
|
||||
const pagedDetailCargoItems = computed(() => {
|
||||
@@ -485,8 +532,7 @@ const progressSummaryItems = computed(() => {
|
||||
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
|
||||
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
|
||||
{ label: '拣货进度', value: pickingProgress },
|
||||
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' },
|
||||
{ label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' },
|
||||
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '—' },
|
||||
{ label: '船期摘要', value: transportReference || '待更新' },
|
||||
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
|
||||
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
|
||||
@@ -598,9 +644,6 @@ watch(isCreatePage, (isCreate, wasCreate) => {
|
||||
watch(detailOpen, (open) => {
|
||||
if (!open) {
|
||||
resetInboundBillingState()
|
||||
customsDetail.value = null
|
||||
customsLoading.value = false
|
||||
customsUnavailable.value = false
|
||||
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
|
||||
}
|
||||
})
|
||||
@@ -1073,7 +1116,6 @@ async function openDetail(order: FirstLegOrder) {
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
void loadInboundBilling(order)
|
||||
void loadCustoms(order)
|
||||
try {
|
||||
const detailedOrder = await store.loadDetail(order.id)
|
||||
// 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 (error instanceof ApiError && error.code === 'A0404') {
|
||||
inboundBillingUnavailable.value = true
|
||||
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
|
||||
inboundBillingUnavailableMessage.value = error.message || '入库及费用结果尚未发布'
|
||||
} else {
|
||||
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() {
|
||||
const order = detailId.value ? store.getById(detailId.value) : undefined
|
||||
if (order) await loadInboundBilling(order)
|
||||
@@ -1203,7 +1230,7 @@ function removeCargoItem(index: number) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1632,12 +1659,22 @@ async function searchOrders(model: QueryModel) {
|
||||
<div class="order-row-actions">
|
||||
<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="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<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 #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 #transportMode="{ row }">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : '-' }}</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-if="filteredOrders.length" class="mobile-order-list">
|
||||
<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>
|
||||
<span v-if="order.businessSummary.inboundCode" class="order-reference">入仓码 {{ order.businessSummary.inboundCode }}</span>
|
||||
<span class="order-reference">{{ order.referenceNo || '无 Reference ID' }}</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>
|
||||
<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>
|
||||
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '—' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
|
||||
<button v-if="order.businessSummary.inboundCode" class="order-reference inbound-code-link" type="button" @click="openInboundCodeAssociation(order.businessSummary.inboundCode)">入仓码 {{ order.businessSummary.inboundCode }}</button>
|
||||
<span v-else class="order-reference">入仓码 —</span>
|
||||
<span class="order-reference">{{ order.referenceNo || '跟踪编号 —' }}</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><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>
|
||||
</div>
|
||||
<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>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>清关申报单价 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>
|
||||
<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>
|
||||
@@ -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-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><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>
|
||||
</table>
|
||||
</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 class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
|
||||
@@ -1839,13 +1884,13 @@ async function searchOrders(model: QueryModel) {
|
||||
</div>
|
||||
<template v-else-if="detailOrder">
|
||||
<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>
|
||||
</div>
|
||||
<section class="detail-section">
|
||||
<h3>订单与运输</h3>
|
||||
<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>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</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>{{ 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.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>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
|
||||
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
|
||||
@@ -1882,9 +1927,9 @@ async function searchOrders(model: QueryModel) {
|
||||
<h3>柜次进度</h3>
|
||||
<div v-if="cabinetProgressItems.length" class="cabinet-progress-list">
|
||||
<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>
|
||||
<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.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div>
|
||||
</dl>
|
||||
@@ -1892,16 +1937,6 @@ async function searchOrders(model: QueryModel) {
|
||||
</div>
|
||||
<el-empty v-else description="暂无柜次分配信息" :image-size="50" />
|
||||
</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">
|
||||
<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>
|
||||
@@ -1964,6 +1999,17 @@ async function searchOrders(model: QueryModel) {
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -2040,6 +2086,9 @@ async function searchOrders(model: QueryModel) {
|
||||
.mobile-order-surface { display: none; min-height: 220px; }
|
||||
.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; }
|
||||
.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-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; }
|
||||
@@ -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 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; }
|
||||
.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 > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.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', () => {
|
||||
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, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/)
|
||||
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)))
|
||||
workspace.close(['first-leg-quick-order'])
|
||||
await router.push({ name: 'first-leg-orders' })
|
||||
const orderNumbers = successes.map((item) => item.orderNo || item.draftId).join('、')
|
||||
ElMessage.success(`已生成 ${successes.length} 张草稿单:${orderNumbers}`)
|
||||
const orderNumbers = successes.map((item) => item.orderNo).filter(Boolean).join('、')
|
||||
ElMessage.success(orderNumbers
|
||||
? `已生成 ${successes.length} 张草稿单:${orderNumbers}`
|
||||
: `已生成 ${successes.length} 张草稿单,订单号 —`)
|
||||
}
|
||||
} catch (error) {
|
||||
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)">
|
||||
<div class="quick-draft-results">
|
||||
<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>
|
||||
</article>
|
||||
</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>
|
||||
.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>
|
||||
|
||||
@@ -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 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() {
|
||||
loading.value = true
|
||||
try {
|
||||
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) })
|
||||
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') {
|
||||
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 {
|
||||
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'
|
||||
? row([item.transferNo, item.inboundCode, item.packageCount, item.operatingWarehouseId, 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.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', 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 : '列表加载失败') }
|
||||
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-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 === '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 === '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>
|
||||
@@ -198,7 +198,7 @@ async function openDetail(value: Record<string, string>) {
|
||||
</form>
|
||||
</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>
|
||||
</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.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: '业务总览',
|
||||
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',
|
||||
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 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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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', () => {
|
||||
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-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-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-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-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/)
|
||||
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', () => {
|
||||
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-logout\s*\{[^}]*height:\s*44px[^}]*background:\s*rgba\(239, 68, 68, \.12\)/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user