diff --git a/.env.example b/.env.example index bfb8669..6144c14 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/client-web.iml b/.idea/client-web.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/client-web.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..07115cd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e8c26cd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 02257ae..df7c55f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 的发布状态与阶段范围,不自动扩展实现 | diff --git a/README.md b/README.md index 39edc79..b2a4b55 100644 --- a/README.md +++ b/README.md @@ -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 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d9e8b3c..c3d40ea 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -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" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index bd49fde..2c714ab 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -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; diff --git a/src/api/crossTabRefresh.test.mjs b/src/api/crossTabRefresh.test.mjs index 44ddd28..200c354 100644 --- a/src/api/crossTabRefresh.test.mjs +++ b/src/api/crossTabRefresh.test.mjs @@ -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 }) + } +}) diff --git a/src/api/crossTabRefresh.ts b/src/api/crossTabRefresh.ts index 6f429ec..11252f2 100644 --- a/src/api/crossTabRefresh.ts +++ b/src/api/crossTabRefresh.ts @@ -1,11 +1,21 @@ export interface CrossTabRefreshOptions { lockName: string + lockTimeoutMs?: number observedRefreshToken: string currentRefreshToken: () => string reuseCurrentSession: () => Promise refresh: (refreshToken: string) => Promise } +export class CrossTabRefreshTimeoutError extends Error { + constructor() { + super('登录状态刷新等待超时,请重新登录') + this.name = 'CrossTabRefreshTimeoutError' + } +} + +const DEFAULT_LOCK_TIMEOUT_MS = 20_000 + export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { const run = async () => { const currentRefreshToken = options.currentRefreshToken() @@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh(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) + } } diff --git a/src/api/http.ts b/src/api/http.ts index 50b2f63..ed8a391 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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(envelope: ApiEnvelope) { 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({ ...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({ ...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 } diff --git a/src/api/types.ts b/src/api/types.ts index f8f6518..e262136 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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' diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 64ba5e7..c32b26a 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -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', () => { diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 88bcd41..f635cd4 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -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:/) diff --git a/src/modules/first-leg/api/customs.test.mjs b/src/modules/first-leg/api/customs.test.mjs new file mode 100644 index 0000000..2e780ee --- /dev/null +++ b/src/modules/first-leg/api/customs.test.mjs @@ -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/) +}) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index bf89f41..6e7d15f 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -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({ +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 = { + 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({ 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({ + 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({ + 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({ + 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({ + 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 { + 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') } diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index fac2bbb..f11a035 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -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 diff --git a/src/modules/first-leg/api/inboundCodeAssociations.test.mjs b/src/modules/first-leg/api/inboundCodeAssociations.test.mjs new file mode 100644 index 0000000..f2b4173 --- /dev/null +++ b/src/modules/first-leg/api/inboundCodeAssociations.test.mjs @@ -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`)) + } +}) diff --git a/src/modules/first-leg/api/inboundCodeAssociations.ts b/src/modules/first-leg/api/inboundCodeAssociations.ts new file mode 100644 index 0000000..48b9392 --- /dev/null +++ b/src/modules/first-leg/api/inboundCodeAssociations.ts @@ -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({ + method: 'GET', + url: `/merchant/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode.trim())}`, + }) +} diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue new file mode 100644 index 0000000..0a975dd --- /dev/null +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index 98edded..083ba22 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -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/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index 0b15be3..ec715c1 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -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<{
计费方式
{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}
换算与精度
-
- 系数 {{ formatInboundNumber(props.data.latestFee.conversionFactor) }} · - 保留 {{ props.data.latestFee.roundingScale }} 位 {{ getRoundingModeLabel(props.data.latestFee.roundingMode) }} -
+
{{ formatConversionPrecision( + props.data.latestFee.conversionFactor, + props.data.latestFee.roundingScale, + props.data.latestFee.roundingMode, + ) }}
实际重量
{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}
实际体积
{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}
diff --git a/src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs b/src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs new file mode 100644 index 0000000..18f0218 --- /dev/null +++ b/src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs @@ -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, /]*width="620px"/) + assert.match(source, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/) + assert.match(source, /min-height: 190px/) +}) diff --git a/src/modules/first-leg/components/InboundCodeAssociationDialog.vue b/src/modules/first-leg/components/InboundCodeAssociationDialog.vue new file mode 100644 index 0000000..12fe916 --- /dev/null +++ b/src/modules/first-leg/components/InboundCodeAssociationDialog.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 17fc525..db004c8 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -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>/) 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\)\)/) }) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index e6e56de..8fd10d9 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -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: '', diff --git a/src/modules/first-leg/types/inboundBilling.ts b/src/modules/first-leg/types/inboundBilling.ts index e700078..68ffe25 100644 --- a/src/modules/first-leg/types/inboundBilling.ts +++ b/src/modules/first-leg/types/inboundBilling.ts @@ -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 diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index 5191897..8f37853 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -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 条货物缺少跟踪编号')) }) diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index 64db76d..02cf989 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -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}件数必须为正整数`) diff --git a/src/modules/first-leg/utils/inboundBilling.test.mjs b/src/modules/first-leg/utils/inboundBilling.test.mjs index bb093f5..fec95b4 100644 --- a/src/modules/first-leg/utils/inboundBilling.test.mjs +++ b/src/modules/first-leg/utils/inboundBilling.test.mjs @@ -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\)\}`/) }) diff --git a/src/modules/first-leg/utils/inboundBilling.ts b/src/modules/first-leg/utils/inboundBilling.ts index 842cd58..600fcad 100644 --- a/src/modules/first-leg/utils/inboundBilling.ts +++ b/src/modules/first-leg/utils/inboundBilling.ts @@ -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 = { 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)}` } diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 0070544..9040d98 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -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, /报关回执<\/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, / diff --git a/src/modules/tenant-management/api/merchantIam.test.mjs b/src/modules/tenant-management/api/merchantIam.test.mjs index ab11e9c..c23acc3 100644 --- a/src/modules/tenant-management/api/merchantIam.test.mjs +++ b/src/modules/tenant-management/api/merchantIam.test.mjs @@ -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\(\)\)/) +}) diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 5c559af..fe985dd 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -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: '财务账单', diff --git a/src/modules/tenant-management/views/MembersView.vue b/src/modules/tenant-management/views/MembersView.vue index b49c681..4dc0154 100644 --- a/src/modules/tenant-management/views/MembersView.vue +++ b/src/modules/tenant-management/views/MembersView.vue @@ -200,7 +200,7 @@ async function resetPassword(member: TenantMember) {
- +
请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。
diff --git a/src/styles/navigationStyles.test.mjs b/src/styles/navigationStyles.test.mjs index 4a19982..46ffdaf 100644 --- a/src/styles/navigationStyles.test.mjs +++ b/src/styles/navigationStyles.test.mjs @@ -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\)/) })