From 7ac173fac42b53cbc28c0054001c014dd92a28ae Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 7 Aug 2026 14:47:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/auth/auth.ts | 64 +- src/core/auth/crossTabRefresh.test.ts | 64 + src/core/auth/crossTabRefresh.ts | 37 + .../customers/pages/CustomersView.test.ts | 1 + src/modules/customers/pages/CustomersView.vue | 22 +- .../api/platformCarrierRates.test.ts | 110 -- .../carrier-rates/api/platformCarrierRates.ts | 117 -- .../api/platformPriceBooks.test.ts | 151 +++ .../carrier-rates/api/platformPriceBooks.ts | 269 ++++ .../pages/CarrierRatesView.test.ts | 141 +-- .../carrier-rates/pages/CarrierRatesView.vue | 1128 ++++++++++------- src/modules/fba-pricing/routes.ts | 2 +- src/modules/iam/api/platformIam.ts | 8 + .../inbounds/fee/InboundFeePanel.test.ts | 8 +- src/modules/inbounds/fee/InboundFeePanel.vue | 4 +- src/modules/inbounds/index.ts | 3 + .../unified-orders/api/unifiedOrders.test.ts | 17 +- .../unified-orders/api/unifiedOrders.ts | 47 +- .../pages/UnifiedOrdersView.test.ts | 23 +- .../pages/UnifiedOrdersView.vue | 287 ++++- .../components/LayoutPreferenceList.vue | 2 +- 21 files changed, 1714 insertions(+), 791 deletions(-) create mode 100644 src/core/auth/crossTabRefresh.test.ts create mode 100644 src/core/auth/crossTabRefresh.ts delete mode 100644 src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts delete mode 100644 src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts create mode 100644 src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts create mode 100644 src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts create mode 100644 src/modules/inbounds/index.ts diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 1a36d44e..137c813b 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -10,10 +10,12 @@ import { import type { PlatformPermissionResource } from '@/shared/types/platformPermission' import { decodeJwtClaims } from './jwt' import { listCurrentPlatformPermissionCodes } from './platformAuthorization' +import { coordinateCrossTabRefresh } from './crossTabRefresh' const TOKEN_KEY = 'cc-admin-token' const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token' const PERMISSION_CODES_KEY = 'cc-admin-permission-codes' +const REFRESH_LOCK_NAME = 'cc-admin-auth-refresh' interface LoginResponse { tokenType: string @@ -117,6 +119,33 @@ export const useAuthStore = defineStore('admin-auth', () => { localStorage.removeItem(PERMISSION_CODES_KEY) } + function applyStoredSession() { + const storedToken = localStorage.getItem(TOKEN_KEY) || '' + const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '' + if (!storedToken) { + sessionGeneration += 1 + token.value = '' + refreshToken.value = '' + user.value = null + permissions.value = [] + permissionResources.value = [] + isServerSession.value = false + return + } + try { + const session = sessionFromToken(storedToken) + const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown + token.value = storedToken + refreshToken.value = storedRefreshToken + user.value = session.user + permissions.value = session.permissions.filter((code) => Array.isArray(cachedPermissions) && cachedPermissions.includes(code)) + permissionResources.value = [] + isServerSession.value = true + } catch { + clearSession() + } + } + async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) { await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, { headers: { Authorization: `Bearer ${accessToken}` }, @@ -146,15 +175,30 @@ export const useAuthStore = defineStore('admin-auth', () => { } async function refreshSession() { - if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录') + const observedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || refreshToken.value + if (!observedRefreshToken) throw new Error('刷新令牌不存在,请重新登录') const expectedGeneration = sessionGeneration try { - const response = await http.post, ApiEnvelope>( - '/auth/refresh', - { refreshToken: refreshToken.value }, - ) - const result = unwrapApiResponse(response) - await applySession(result, expectedGeneration) + await coordinateCrossTabRefresh({ + lockName: REFRESH_LOCK_NAME, + observedRefreshToken, + currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '', + reuseCurrentSession: async () => { + const storedAccessToken = localStorage.getItem(TOKEN_KEY) || '' + const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '' + if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期') + await applySession({ tokenType: 'Bearer', accessToken: storedAccessToken, expiresIn: 0, refreshToken: storedRefreshToken, refreshExpiresIn: 0 }, expectedGeneration) + }, + refresh: async (currentRefreshToken) => { + const response = await http.post, ApiEnvelope>( + '/auth/refresh', + { refreshToken: currentRefreshToken }, + ) + ensureCurrentGeneration(expectedGeneration) + if (!localStorage.getItem(REFRESH_TOKEN_KEY)) throw new SupersededSessionError('登录会话已在其他标签页退出') + await applySession(unwrapApiResponse(response), expectedGeneration) + }, + }) } catch (error) { if (expectedGeneration === sessionGeneration) clearSession() throw error @@ -186,6 +230,12 @@ export const useAuthStore = defineStore('admin-auth', () => { return token.value }) + if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if ([TOKEN_KEY, REFRESH_TOKEN_KEY, PERMISSION_CODES_KEY].includes(event.key || '')) applyStoredSession() + }) + } + async function logout() { const accessToken = token.value const tokenToRevoke = refreshToken.value diff --git a/src/core/auth/crossTabRefresh.test.ts b/src/core/auth/crossTabRefresh.test.ts new file mode 100644 index 00000000..2fc9ea5c --- /dev/null +++ b/src/core/auth/crossTabRefresh.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { coordinateCrossTabRefresh } from './crossTabRefresh' + +afterEach(() => vi.unstubAllGlobals()) + +describe('cross-tab refresh coordination', () => { + it('reuses the rotated session after waiting for another tab', async () => { + let currentRefreshToken = 'refresh-old' + const refresh = vi.fn() + const reuseCurrentSession = vi.fn(async () => 'access-new') + vi.stubGlobal('navigator', { + locks: { + request: vi.fn(async (_name: string, callback: () => Promise) => { + currentRefreshToken = 'refresh-new' + return callback() + }), + }, + }) + + await expect(coordinateCrossTabRefresh({ + lockName: 'admin-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => currentRefreshToken, + reuseCurrentSession, + refresh, + })).resolves.toBe('access-new') + + expect(reuseCurrentSession).toHaveBeenCalledOnce() + expect(refresh).not.toHaveBeenCalled() + }) + + it('performs the refresh when the token has not changed', async () => { + const refresh = vi.fn(async () => 'access-new') + vi.stubGlobal('navigator', { + locks: { request: vi.fn(async (_name: string, callback: () => Promise) => callback()) }, + }) + + await expect(coordinateCrossTabRefresh({ + lockName: 'admin-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: vi.fn(), + refresh, + })).resolves.toBe('access-new') + + expect(refresh).toHaveBeenCalledWith('refresh-old') + }) + + it('does not wait for rotation after the shared session was removed', async () => { + const refreshError = new Error('session removed') + const refresh = vi.fn(async () => { throw refreshError }) + vi.stubGlobal('navigator', {}) + + await expect(coordinateCrossTabRefresh({ + lockName: 'admin-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => '', + reuseCurrentSession: vi.fn(), + refresh, + })).rejects.toBe(refreshError) + + expect(refresh).toHaveBeenCalledWith('refresh-old') + }) +}) diff --git a/src/core/auth/crossTabRefresh.ts b/src/core/auth/crossTabRefresh.ts new file mode 100644 index 00000000..6f429ec1 --- /dev/null +++ b/src/core/auth/crossTabRefresh.ts @@ -0,0 +1,37 @@ +export interface CrossTabRefreshOptions { + lockName: string + observedRefreshToken: string + currentRefreshToken: () => string + reuseCurrentSession: () => Promise + refresh: (refreshToken: string) => Promise +} + +export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { + const run = async () => { + const currentRefreshToken = options.currentRefreshToken() + if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) { + return options.reuseCurrentSession() + } + return options.refresh(currentRefreshToken || options.observedRefreshToken) + } + + if (typeof navigator === 'undefined' || !navigator.locks) { + try { + return await run() + } catch (error) { + if (!options.currentRefreshToken()) throw error + // Web Locks requires a secure context. On plain HTTP, briefly allow another tab's + // successful rotation to reach shared storage before treating this session as expired. + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)) + const currentRefreshToken = options.currentRefreshToken() + if (!currentRefreshToken) throw error + if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) { + return options.reuseCurrentSession() + } + } + throw error + } + } + return navigator.locks.request(options.lockName, run) +} diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index b5d89708..c832038c 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -34,6 +34,7 @@ vi.mock('vue-router', async (importOriginal) => ({ vi.mock('@/modules/iam', () => ({ approveEnterpriseReview: vi.fn(), changePlatformTenantStatus: vi.fn(), + checkPlatformTenantContactPhone: vi.fn().mockResolvedValue(true), createPlatformTenant: vi.fn(), downloadTenantQualification: vi.fn(), getPlatformTenant: vi.fn(), diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 26575269..c3472aa8 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -8,6 +8,7 @@ import { createPlatformExportTask } from '@/modules/downloads' import { approveEnterpriseReview, changePlatformTenantStatus, + checkPlatformTenantContactPhone, createPlatformTenant, downloadTenantQualification, getPlatformTenant, @@ -112,7 +113,10 @@ const rules: FormRules = { legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }], unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }], contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }], - contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }], + contactPhone: [ + { required: true, message: '请输入联系电话', trigger: 'blur' }, + { pattern: /^[0-9-]{4,32}$/, message: '请输入 4-32 位数字或连字符,联系电话将作为登录用户名', trigger: 'blur' }, + ], contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }], servicePackage: [{ required: true, message: '请选择客户套餐', trigger: 'change' }], paymentTermDays: [{ required: true, message: '请输入账期天数', trigger: 'change' }], @@ -490,6 +494,18 @@ async function uploadSelectedQualifications(tenantId: string) { async function saveCustomer() { if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return + if (!editingId.value) { + try { + const available = await checkPlatformTenantContactPhone(form.value.contactPhone.trim()) + if (!available) { + ElMessage.error('联系电话已被其他客户使用,请更换后再提交') + return + } + } catch (error) { + ElMessage.error(getApiErrorMessage(error, '联系电话唯一性校验失败,请稍后重试')) + return + } + } const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode) if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) { ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`) @@ -693,7 +709,7 @@ onMounted(async () => {

联系与商务

- + 客户已开户,联系电话作为登录用户名不可修改 @@ -764,7 +780,7 @@ onMounted(async () => { .customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; } .capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; } .qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); } -.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; } +.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; } .qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; } .qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); } .qualification-optional { padding:2px 5px; border-radius:4px; background:var(--el-fill-color-light); color:var(--cc-muted); font-size:10px; font-weight:500; line-height:1.4; } diff --git a/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts b/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts deleted file mode 100644 index 3afba370..00000000 --- a/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), - delete: vi.fn(), -})) - -vi.mock('@/shared/api/http', () => ({ - http: mocks, - unwrapApiResponse: (response: { data: T }) => response.data, -})) - -import { - createPlatformCarrierRateVersion, - getPlatformCarrierRate, - listPlatformCarrierRates, - retirePlatformCarrierRate, -} from './platformCarrierRates' - -const payload = { - id: '9007199254740995', - tenantId: '0', - rateCode: 'RATE-US-WEST', - rateVersion: 2, - originWarehouseId: '20001', - originWarehouseName: '义乌收货仓', - businessType: 'FBA_SEA', - channelCode: 'US-WEST-SEA', - carrierId: '30001', - carrierName: '示例船司', - destinationCode: 'ONT8', - chargeUnit: 'KG' as const, - unitPrice: 12.5, - currency: 'CNY', - additionalFee: 80, - effectiveFrom: '2026-08-01T00:00:00', - effectiveTo: null, - status: 'ACTIVE' as const, - remark: null, - createdBy: '10', - createdAt: '2026-08-01T10:00:00', - updatedBy: null, - updatedAt: null, - version: 1, -} - -describe('platformCarrierRates api', () => { - beforeEach(() => vi.clearAllMocks()) - - it('queries the platform-wide carrier-rate path and keeps every long id as a string', async () => { - mocks.get.mockResolvedValue({ data: [payload] }) - - const result = await listPlatformCarrierRates({ rateCode: ' RATE-US ' }) - - expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates', { - params: { rateCode: 'RATE-US' }, - }) - expect(result[0]).toMatchObject({ - id: '9007199254740995', - tenantId: '0', - originWarehouseId: '20001', - carrierId: '30001', - }) - }) - - it('omits the tenant parameter for the platform-wide list query', async () => { - mocks.get.mockResolvedValue({ data: [payload] }) - - await listPlatformCarrierRates() - await listPlatformCarrierRates({ rateCode: ' ' }) - - expect(mocks.get).toHaveBeenNthCalledWith(1, '/platform/v1/fba-pricing/carrier-rates', { params: {} }) - expect(mocks.get).toHaveBeenNthCalledWith(2, '/platform/v1/fba-pricing/carrier-rates', { params: {} }) - }) - - it('loads a platform-wide version detail without tenant scope', async () => { - mocks.get.mockResolvedValue({ data: payload }) - await getPlatformCarrierRate('9001') - expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001') - }) - - it('creates immutable versions and retires an active version', async () => { - mocks.post.mockResolvedValue({ data: payload }) - mocks.delete.mockResolvedValue({ data: null }) - const request = { - rateCode: 'RATE-US-WEST', - originWarehouseId: '20001', - originWarehouseName: '义乌收货仓', - businessType: 'FBA_SEA', - channelCode: 'US-WEST-SEA', - carrierId: '30001', - carrierName: '示例船司', - destinationCode: 'ONT8', - chargeUnit: 'KG' as const, - unitPrice: 12.5, - currency: 'CNY', - additionalFee: 80, - effectiveFrom: '2026-08-01T00:00:00', - effectiveTo: null, - remark: null, - } - - await createPlatformCarrierRateVersion(request) - await retirePlatformCarrierRate('9001') - - expect(mocks.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/versions', request) - expect(mocks.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001') - }) -}) diff --git a/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts b/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts deleted file mode 100644 index 8e861c01..00000000 --- a/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' - -export type CarrierRateChargeUnit = 'KG' | 'CBM' -export type CarrierRateStatus = 'ACTIVE' | 'INACTIVE' - -export interface PlatformCarrierRateResource { - id: string - tenantId: string - rateCode: string - rateVersion: number - originWarehouseId: string - originWarehouseName: string - businessType: string - channelCode: string - carrierId: string - carrierName: string - destinationCode: string - chargeUnit: CarrierRateChargeUnit - unitPrice: number - currency: string - additionalFee: number - effectiveFrom: string - effectiveTo: string | null - status: CarrierRateStatus - remark: string | null - createdBy: string | null - createdAt: string | null - updatedBy: string | null - updatedAt: string | null - version: number | null -} - -interface PlatformCarrierRatePayload extends Omit { - id: string | number - tenantId: string | number - originWarehouseId: string | number - carrierId: string | number - createdBy?: string | number | null - updatedBy?: string | number | null -} - -export interface CreatePlatformCarrierRateVersionRequest { - rateCode: string - originWarehouseId: string - originWarehouseName: string - businessType: string - channelCode: string - carrierId: string - carrierName: string - destinationCode: string - chargeUnit: CarrierRateChargeUnit - unitPrice: number - currency: string - additionalFee: number - effectiveFrom: string - effectiveTo: string | null - remark: string | null -} - -export interface PlatformCarrierRateQuery { - rateCode?: string -} - -const basePath = '/platform/v1/fba-pricing/carrier-rates' - -function nullableId(value: string | number | null | undefined) { - return value == null ? null : String(value) -} - -function normalizeCarrierRate(value: PlatformCarrierRatePayload): PlatformCarrierRateResource { - return { - ...value, - id: String(value.id), - tenantId: String(value.tenantId), - originWarehouseId: String(value.originWarehouseId), - carrierId: String(value.carrierId), - createdBy: nullableId(value.createdBy), - updatedBy: nullableId(value.updatedBy), - effectiveTo: value.effectiveTo ?? null, - remark: value.remark ?? null, - createdAt: value.createdAt ?? null, - updatedAt: value.updatedAt ?? null, - version: value.version ?? null, - } -} - -export async function listPlatformCarrierRates(query: PlatformCarrierRateQuery = {}) { - const rateCode = query.rateCode?.trim() - const response = await http.get, ApiEnvelope>( - basePath, - { params: rateCode ? { rateCode } : {} }, - ) - return unwrapApiResponse(response).map(normalizeCarrierRate) -} - -export async function getPlatformCarrierRate(id: string) { - const response = await http.get, ApiEnvelope>( - `${basePath}/${id}`, - ) - return normalizeCarrierRate(unwrapApiResponse(response)) -} - -export async function createPlatformCarrierRateVersion( - payload: CreatePlatformCarrierRateVersionRequest, -) { - const response = await http.post, ApiEnvelope>( - `${basePath}/versions`, - payload, - ) - return normalizeCarrierRate(unwrapApiResponse(response)) -} - -export async function retirePlatformCarrierRate(id: string) { - const response = await http.delete, ApiEnvelope>(`${basePath}/${id}`) - return unwrapApiResponse(response) -} diff --git a/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts new file mode 100644 index 00000000..faf2a07c --- /dev/null +++ b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const http = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})) + +vi.mock('@/shared/api/http', () => ({ + http, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +import { + downloadPlatformPriceBookSource, + getPlatformPriceBook, + listPlatformPriceBooks, + publishPlatformPriceBook, + uploadPlatformPriceBook, +} from './platformPriceBooks' + +const version = { + id: '9007199254740995', + versionNo: '2', + originalFileName: '2026-08-price-book.xlsx', + sourceFileId: '9007199254740996', + sourceSha256: 'a'.repeat(64), + status: 'PENDING_CONFIRMATION' as const, + channelCount: '15', + rateCount: '42', + blockingErrorCount: '2', + warningCount: 1, + parseIssuesJson: null, + createdBy: '10001', + createdAt: '2026-08-07T10:00:00', + publishedBy: null, + publishedAt: null, + supersededAt: null, +} + +const detail = { + version, + channels: [{ + channelCode: 'MASON_TRUCK', + channelName: '美森正班海卡', + priceModel: 'FBA_WAREHOUSE_RATE' as const, + sourceSheetName: '美森正班海卡', + estimatedDepartureDate: '2026-08-12', + estimatedDepartureText: '8月12日', + latestWarehouseEntry: null, + vesselName: null, + voyageNo: null, + referenceTransit: null, + surchargeDescription: null, + remark: null, + }], + rates: [{ + id: '9007199254740997', + channelCode: 'MASON_TRUCK', + priceModel: 'FBA_WAREHOUSE_RATE' as const, + originWarehouseId: null, + originWarehouseCode: 'CN-YIWU-01', + originWarehouseName: '义乌仓', + destinationType: 'OVERSEAS' as const, + destinationCode: 'US-LAX-01', + destinationWarehouseId: null, + destinationName: '洛杉矶仓', + regionCode: null, + pricingTaxMode: 'TAX_INCLUDED' as const, + chargeUnit: 'KG' as const, + weightFromKg: null, + unitPrice: '12.5', + currency: 'CNY', + sourceSheetName: '美森正班海卡', + sourceRowNumber: '8', + sourceDestinationText: '洛杉矶仓', + remark: null, + }], + issues: [{ + severity: 'BLOCKING', + issueCode: 'ORIGIN_MAPPING_REQUIRED', + message: '起运仓需要映射到平台国内仓', + sourceSheetName: '美森正班海卡', + sourceRowNumber: '8', + sourceCell: null, + }], +} + +describe('platform price-book API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('uses the platform price-book list endpoint without customer scope and preserves long ids', async () => { + http.get.mockResolvedValue({ data: [version] }) + + const result = await listPlatformPriceBooks({ status: ' PENDING_CONFIRMATION ' }) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', { + params: { status: 'PENDING_CONFIRMATION' }, + }) + expect(result[0]).toMatchObject({ + id: '9007199254740995', + sourceFileId: '9007199254740996', + createdBy: '10001', + versionNo: 2, + rateCount: 42, + }) + }) + + it('omits an empty status filter for the complete version history', async () => { + http.get.mockResolvedValue({ data: [] }) + + await listPlatformPriceBooks({ status: ' ' }) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', { params: {} }) + }) + + it('uploads one workbook, loads its parsed detail, and normalizes parsed rows', async () => { + http.post.mockResolvedValue({ data: detail }) + http.get.mockResolvedValue({ data: detail }) + const file = new File(['book'], 'price-book.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + + const uploadResult = await uploadPlatformPriceBook(file) + const detailResult = await getPlatformPriceBook('9007199254740995') + + expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', expect.any(FormData)) + const form = http.post.mock.calls[0][1] as FormData + expect(form.get('file')).toBe(file) + expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books/9007199254740995') + expect(uploadResult.rates[0]).toMatchObject({ id: '9007199254740997', unitPrice: 12.5, sourceRowNumber: 8 }) + expect(detailResult.issues[0].sourceRowNumber).toBe(8) + }) + + it('publishes only confirmed warehouse mappings and downloads the original workbook', async () => { + http.post.mockResolvedValue({ data: detail }) + const request = { + originMappings: [{ originWarehouseCode: 'CN-YIWU-01', warehouseId: '20001' }], + overseasMappings: [{ sourceDestinationName: '洛杉矶仓', warehouseId: '20011' }], + } + + await publishPlatformPriceBook('9007199254740995', request) + await downloadPlatformPriceBookSource('9007199254740995') + + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/fba-pricing/carrier-rates/price-books/9007199254740995/publish', + request, + ) + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/fba-pricing/carrier-rates/price-books/9007199254740995/source', + { responseType: 'blob' }, + ) + }) +}) diff --git a/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts new file mode 100644 index 00000000..81b64e6c --- /dev/null +++ b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts @@ -0,0 +1,269 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type PriceBookStatus = 'PENDING_CONFIRMATION' | 'ACTIVE' | 'SUPERSEDED' | 'FAILED' +export type PriceModel = 'FBA_WAREHOUSE_RATE' | 'REGION_WEIGHT_TIER' +export type PricingTaxMode = 'TAX_INCLUDED' | 'TAX_EXCLUDED' +export type PricingDestinationType = 'FBA' | 'OVERSEAS' +export type PriceBookChargeUnit = 'KG' | 'CBM' + +type ApiId = string | number +type ApiNumber = string | number + +export interface PlatformPriceBookVersion { + id: string + versionNo: number + originalFileName: string + sourceFileId: string | null + sourceSha256: string | null + status: PriceBookStatus + channelCount: number + rateCount: number + blockingErrorCount: number + warningCount: number + parseIssuesJson: string | null + createdBy: string | null + createdAt: string | null + publishedBy: string | null + publishedAt: string | null + supersededAt: string | null +} + +/** Read-only channel data parsed from the uploaded workbook. */ +export interface PlatformPriceBookParsedChannel { + channelCode: string + channelName: string + priceModel: PriceModel + sourceSheetName: string + estimatedDepartureDate: string | null + estimatedDepartureText: string | null + latestWarehouseEntry: string | null + vesselName: string | null + voyageNo: string | null + referenceTransit: string | null + surchargeDescription: string | null + remark: string | null +} + +/** Read-only price row parsed from the uploaded workbook. */ +export interface PlatformPriceBookParsedRate { + id: string | null + channelCode: string + priceModel: PriceModel + originWarehouseId: string | null + originWarehouseCode: string + originWarehouseName: string + destinationType: PricingDestinationType | null + destinationCode: string | null + destinationWarehouseId: string | null + destinationName: string | null + regionCode: string | null + pricingTaxMode: PricingTaxMode | null + chargeUnit: PriceBookChargeUnit + weightFromKg: number | null + unitPrice: number + currency: string + sourceSheetName: string + sourceRowNumber: number + sourceDestinationText: string | null + remark: string | null +} + +export interface PlatformPriceBookParseIssue { + severity: 'BLOCKING' | 'WARNING' | string + issueCode: string + message: string + sourceSheetName: string | null + sourceRowNumber: number | null + sourceCell: string | null +} + +export interface PlatformPriceBookDetail { + version: PlatformPriceBookVersion + channels: PlatformPriceBookParsedChannel[] + rates: PlatformPriceBookParsedRate[] + issues: PlatformPriceBookParseIssue[] +} + +export interface PriceBookConfirmationRequest { + originMappings: Array<{ + originWarehouseCode: string + warehouseId: string + }> + overseasMappings: Array<{ + sourceDestinationName: string + warehouseId: string + }> +} + +export interface PlatformPriceBookQuery { + status?: PriceBookStatus | string +} + +interface PriceBookVersionPayload extends Omit { + id: ApiId + versionNo: ApiNumber + sourceFileId?: ApiId | null + channelCount?: ApiNumber | null + rateCount?: ApiNumber | null + blockingErrorCount?: ApiNumber | null + warningCount?: ApiNumber | null + createdBy?: ApiId | null + publishedBy?: ApiId | null +} + +interface PriceBookParsedChannelPayload extends Omit { + estimatedDepartureDate?: string | null + estimatedDepartureText?: string | null + latestWarehouseEntry?: string | null + vesselName?: string | null + voyageNo?: string | null + referenceTransit?: string | null + surchargeDescription?: string | null + remark?: string | null +} + +interface PriceBookParsedRatePayload extends Omit { + id?: ApiId | null + originWarehouseId?: ApiId | null + destinationWarehouseId?: ApiId | null + weightFromKg?: ApiNumber | null + unitPrice: ApiNumber + sourceRowNumber: ApiNumber +} + +interface PriceBookParseIssuePayload extends Omit { + sourceRowNumber?: ApiNumber | null +} + +interface PriceBookDetailPayload { + version: PriceBookVersionPayload + channels?: PriceBookParsedChannelPayload[] | null + rates?: PriceBookParsedRatePayload[] | null + issues?: PriceBookParseIssuePayload[] | null +} + +const basePath = '/platform/v1/fba-pricing/carrier-rates/price-books' + +function nullableId(value: ApiId | null | undefined) { + return value == null ? null : String(value) +} + +function nullableText(value: string | null | undefined) { + return value ?? null +} + +function numberValue(value: ApiNumber | null | undefined) { + const number = Number(value ?? 0) + return Number.isFinite(number) ? number : 0 +} + +function normalizeVersion(value: PriceBookVersionPayload): PlatformPriceBookVersion { + return { + ...value, + id: String(value.id), + versionNo: numberValue(value.versionNo), + sourceFileId: nullableId(value.sourceFileId), + channelCount: numberValue(value.channelCount), + rateCount: numberValue(value.rateCount), + blockingErrorCount: numberValue(value.blockingErrorCount), + warningCount: numberValue(value.warningCount), + parseIssuesJson: nullableText(value.parseIssuesJson), + createdBy: nullableId(value.createdBy), + createdAt: nullableText(value.createdAt), + publishedBy: nullableId(value.publishedBy), + publishedAt: nullableText(value.publishedAt), + supersededAt: nullableText(value.supersededAt), + } +} + +function normalizeChannel(value: PriceBookParsedChannelPayload): PlatformPriceBookParsedChannel { + return { + ...value, + estimatedDepartureDate: nullableText(value.estimatedDepartureDate), + estimatedDepartureText: nullableText(value.estimatedDepartureText), + latestWarehouseEntry: nullableText(value.latestWarehouseEntry), + vesselName: nullableText(value.vesselName), + voyageNo: nullableText(value.voyageNo), + referenceTransit: nullableText(value.referenceTransit), + surchargeDescription: nullableText(value.surchargeDescription), + remark: nullableText(value.remark), + } +} + +function normalizeRate(value: PriceBookParsedRatePayload): PlatformPriceBookParsedRate { + return { + ...value, + id: nullableId(value.id), + originWarehouseId: nullableId(value.originWarehouseId), + destinationWarehouseId: nullableId(value.destinationWarehouseId), + destinationType: value.destinationType ?? null, + destinationCode: nullableText(value.destinationCode), + destinationName: nullableText(value.destinationName), + regionCode: nullableText(value.regionCode), + pricingTaxMode: value.pricingTaxMode ?? null, + weightFromKg: value.weightFromKg == null ? null : numberValue(value.weightFromKg), + unitPrice: numberValue(value.unitPrice), + sourceRowNumber: numberValue(value.sourceRowNumber), + sourceDestinationText: nullableText(value.sourceDestinationText), + remark: nullableText(value.remark), + } +} + +function normalizeIssue(value: PriceBookParseIssuePayload): PlatformPriceBookParseIssue { + return { + ...value, + sourceSheetName: nullableText(value.sourceSheetName), + sourceRowNumber: value.sourceRowNumber == null ? null : numberValue(value.sourceRowNumber), + sourceCell: nullableText(value.sourceCell), + } +} + +function normalizeDetail(value: PriceBookDetailPayload): PlatformPriceBookDetail { + return { + version: normalizeVersion(value.version), + channels: (value.channels || []).map(normalizeChannel), + rates: (value.rates || []).map(normalizeRate), + issues: (value.issues || []).map(normalizeIssue), + } +} + +export async function listPlatformPriceBooks(query: PlatformPriceBookQuery = {}): Promise { + const status = query.status?.trim() + const response = await http.get, ApiEnvelope>(basePath, { + params: status ? { status } : {}, + }) + return unwrapApiResponse(response).map(normalizeVersion) +} + +export async function getPlatformPriceBook(versionId: string): Promise { + const response = await http.get, ApiEnvelope>( + `${basePath}/${encodeURIComponent(versionId)}`, + ) + return normalizeDetail(unwrapApiResponse(response)) +} + +export async function uploadPlatformPriceBook(file: File): Promise { + const form = new FormData() + form.append('file', file) + const response = await http.post, ApiEnvelope>(basePath, form) + return normalizeDetail(unwrapApiResponse(response)) +} + +export async function publishPlatformPriceBook( + versionId: string, + request: PriceBookConfirmationRequest, +): Promise { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(versionId)}/publish`, + request, + ) + return normalizeDetail(unwrapApiResponse(response)) +} + +export function downloadPlatformPriceBookSource(versionId: string) { + return http.get(`${basePath}/${encodeURIComponent(versionId)}/source`, { responseType: 'blob' }) +} diff --git a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts index 257bf5a8..499b6225 100644 --- a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts +++ b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts @@ -5,102 +5,81 @@ import { describe, expect, it } from 'vitest' const source = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue'), 'utf8') const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8') -describe('CarrierRatesView source contract', () => { - it('keeps operation column first and covers every required business field', () => { - const scopeColumn = "key: 'scope', label: '适用范围'" - const destinationColumn = "key: 'destinationCode', prop: 'destinationCode'" - expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf(scopeColumn)) - expect(source.indexOf(scopeColumn)).toBeLessThan(source.indexOf(destinationColumn)) - expect(source.indexOf(destinationColumn)).toBeLessThan(source.indexOf("key: 'rate'")) - for (const label of ['起运仓', '业务类型', '渠道编码', '船司', 'FBA 目的仓', '计费单位', '单价', '币种', '固定附加费', '有效期', '备注', '生效时间', '结束时间']) { +describe('CarrierRatesView price-book source contract', () => { + it('keeps the operation column first and presents version-level history', () => { + expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("key: 'version'")) + for (const label of ['价格表版本', '原始文件', '解析结果', '校验结果', '上传时间', '实际生效区间']) { expect(source).toContain(label) } - expect(source).toContain("placeholder: 'FBA 目的仓编码'") - expect(source).toContain("placeholder: '渠道编码'") + expect(source).toContain('formatEffectivePeriod') + expect(source).toContain('PENDING_CONFIRMATION') + expect(source).toContain('SUPERSEDED') }) - it('implements immutable version actions with exact backend permissions', () => { - expect(source).toContain("firstleg:fba-pricing:rate:edit") - expect(source).toContain('创建版本') - expect(source).toContain('新版本') - expect(source).toContain('停用') - expect(source).not.toContain('编辑价格') + it('uses only the parsed price-book workflow instead of legacy carrier-rate editing', () => { + for (const capability of [ + 'listPlatformPriceBooks', + 'getPlatformPriceBook', + 'uploadPlatformPriceBook', + 'publishPlatformPriceBook', + 'downloadPlatformPriceBookSource', + ]) expect(source).toContain(capability) + + for (const legacyCapability of [ + 'createPlatformCarrierRateVersion', + 'listPlatformCarrierRates', + 'retirePlatformCarrierRate', + '新增价格版本', + '编辑价格', + '有效期', + '生效日期', + ]) expect(source).not.toContain(legacyCapability) }) - it('treats carrier rates as platform-wide data without a customer selector', () => { - expect(source).toContain("key: 'scope', label: '适用范围'") - expect(source).toContain('全部客户') - expect(source).not.toContain("key: 'customerId'") - expect(source).not.toContain('listPlatformTenants') - expect(source).not.toContain('PlatformTenantSelector') - expect(source).not.toContain('selectedTenantId') - expect(source).not.toContain('租户') + it('keeps uploaded prices immutable and makes confirmation the only edit action', () => { + expect(source).toContain('上传并解析') + expect(source).toContain('查看并确认') + expect(source).toContain('确认并立即生效') + expect(source).toContain('价格行不可在页面内修改') + expect(source).toContain('confirmPriceBook') + expect(source).toContain('publishPlatformPriceBook') + expect(source).not.toContain('草稿') }) - it('uses semantic compact filter widths without a customer field', () => { - for (const field of [ - "key: 'rateCode', type: 'input', width: 220", - "key: 'destinationCode', type: 'input', width: 220", - "key: 'channelCode', type: 'input', width: 220", - "key: 'chargeUnit', type: 'select', width: 180", - "key: 'status', type: 'select', width: 160", - ]) expect(source).toContain(field) - expect(source).not.toContain(':search-on-reset="false"') - expect(source).not.toContain('请选择所属企业并查询价格数据') - expect(source).not.toContain('customers.value[0]') + it('maps only active platform public domestic and overseas warehouses before confirmation', () => { + expect(source).toContain('listAllPlatformWarehouses()') + expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'") + expect(source).toContain("warehouse.warehouseType === 'OVERSEAS'") + expect(source).toContain('起运仓映射') + expect(source).toContain('海外仓映射') + expect(source).toContain('originWarehouseCode') + expect(source).toContain('sourceDestinationName') + expect(source).not.toContain('tenantId') + expect(source).not.toContain('客户选择') }) - it('loads the platform-wide list and keeps row actions independent of customer scope', () => { - expect(source).toContain('void loadRateData()') - expect(source).toContain('listPlatformCarrierRates({ rateCode: filters.value.rateCode })') - expect(source).toContain('getPlatformCarrierRate(row.id)') - expect(source).toContain('retirePlatformCarrierRate(row.id)') - expect(source).toContain('
适用范围
全部客户
') + it('shows parsing blockers and a channel-tabbed, read-only preview', () => { + expect(source).toContain('nonMappingBlockingIssues') + expect(source).toContain('解析与校验') + expect(source).toContain('按渠道预览') + expect(source).toContain(' { - expect(source).toContain('listPlatformPartners({ page: 1, size: 200, type: \'CARRIER\', status: \'ACTIVE\' })') - expect(source).toContain('平台仓库或船司资料加载失败') - expect(source).not.toContain('listPlatformPartners(customerId,') - expect(source).not.toContain('所属企业的仓库或船司资料加载失败') - expect(source).toContain('async function loadPlatformMasterData()') - expect(source).not.toContain('loadCustomerMasterData') + it('accepts only xlsx uploads and retains original-file download', () => { + expect(source).toContain('accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"') + expect(source).toContain("endsWith('.xlsx')") + expect(source).toContain('10 * 1024 * 1024') + expect(source).toContain('downloadSource') + expect(source).toContain('saveBlob') }) - it('keeps public rate queries independent from edit-only master data', () => { - expect(source).toContain('await loadRateData()') - expect(source).not.toContain('loadPlatformMasterData(customerId)') - }) - - it('inherits QueryToolbar wrapping and full-width mobile controls', () => { - expect(queryToolbarSource).toContain('flex-wrap: wrap') - expect(queryToolbarSource).toContain('@media (max-width: 720px)') - expect(queryToolbarSource).toContain('width: 100%') - expect(source).not.toContain('--query-field-width') - }) - - it('guards unsaved work, failures and narrow screens', () => { - expect(source).toContain('useUnsavedWorkRegistration') + it('uses the shared query and table primitives with mobile layout handling', () => { + expect(source).toContain("import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'") + expect(source).toContain("import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'") expect(source).toContain('LoadErrorState') - expect(source).toContain('ApiErrorMeta') + expect(queryToolbarSource).toContain('flex-wrap: wrap') expect(source).toContain('@media (max-width: 720px)') }) - - it('reuses the first-leg order list primitives for status, empty state and pagination', () => { - expect(source).toContain("import StatusBadge from '@/shared/components/StatusBadge.vue'") - expect(source).toContain('') - expect(source).not.toContain('') - expect(source).not.toContain(' { - expect(source).not.toContain('createRouter') - expect(source).not.toContain('adminNavigation') - for (const unsupportedCapability of ['导入', '导出', '全部船期', '参考时效', '最晚进仓时间']) { - expect(source).not.toContain(unsupportedCapability) - } - }) }) diff --git a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue index 94a0feef..7aa91056 100644 --- a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue +++ b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue @@ -1,419 +1,584 @@ diff --git a/src/modules/fba-pricing/routes.ts b/src/modules/fba-pricing/routes.ts index dc2eea53..c2849631 100644 --- a/src/modules/fba-pricing/routes.ts +++ b/src/modules/fba-pricing/routes.ts @@ -7,7 +7,7 @@ export const fbaPricingRoutes = [ component: () => import('./carrier-rates/pages/CarrierRatesView.vue'), meta: { title: '船期价格表', - description: '维护起运仓、渠道、船司和 FBA 目的仓价格版本', + description: '上传、确认并查询平台公共船期价格表版本', permission: 'firstleg:fba-pricing:rate:view', }, }, diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index aae4028d..b0f08685 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -326,6 +326,14 @@ export function getPlatformTenant(tenantId: number | string) { return getData(`/platform/v1/iam/tenants/${tenantId}`) } +export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) { + const response = await http.get, ApiEnvelope>( + '/platform/v1/iam/tenants/contact-phone-availability', + { params: { contactPhone, ...(excludeTenantId ? { excludeTenantId } : {}) } }, + ) + return unwrapApiResponse(response) +} + export function createPlatformTenant(payload: CreatePlatformTenantRequest) { return postData('/platform/v1/iam/tenants', payload) } diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 8614624f..b00ee64c 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -112,11 +112,11 @@ describe('InboundFeePanel', () => { it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => { const longRuleCode = `RULE-${'LONG-'.repeat(20)}` - const longCarrierName = `船司${'超长名称'.repeat(20)}` + const longChannelCode = `CHANNEL-${'LONG-'.repeat(20)}` vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, ruleCode: longRuleCode, - carrierName: longCarrierName, + channelCode: longChannelCode, confirmedAt: null, sentAt: null, }) @@ -138,10 +138,10 @@ describe('InboundFeePanel', () => { ]) expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([ '客户计费规则', - '船司 FBA 价格', + '渠道价格', ]) expect(content).toContain(longRuleCode) - expect(content).toContain(longCarrierName) + expect(content).toContain(longChannelCode) const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending') expect(pendingTimelineItems).toHaveLength(2) expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—']) diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index 5b57e0a5..f1f0fd2c 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -320,10 +320,10 @@ defineExpose({ reload: loadFee })
-

船司 FBA 价格

{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}

+

渠道价格

{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}

起运仓
{{ snapshot.originWarehouseName }}
-
渠道 / 船司
{{ snapshot.channelCode }} / {{ snapshot.carrierName }}
+
渠道
{{ snapshot.channelCode }}
FBA 目的仓
{{ snapshot.destinationCode }}
价格有效期
{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}
diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts new file mode 100644 index 00000000..6422f8e6 --- /dev/null +++ b/src/modules/inbounds/index.ts @@ -0,0 +1,3 @@ +export { getInboundMeasurement } from './measurement/api' +export { downloadWeightImage } from './weight/api/weightOcr' +export type { InboundMeasurementDetail } from './measurement/types' diff --git a/src/modules/unified-orders/api/unifiedOrders.test.ts b/src/modules/unified-orders/api/unifiedOrders.test.ts index a61853b9..c63da301 100644 --- a/src/modules/unified-orders/api/unifiedOrders.test.ts +++ b/src/modules/unified-orders/api/unifiedOrders.test.ts @@ -3,9 +3,9 @@ import { http } from '@/shared/api/http' import { approvePlatformUnifiedOrder, addPlatformUnifiedOrderNote, - assignPlatformUnifiedOrderCarrier, getPlatformUnifiedOrder, listPlatformUnifiedOrders, + listPlatformUnifiedOrderFeeDetails, } from './unifiedOrders' vi.mock('@/shared/api/http', () => ({ @@ -52,11 +52,11 @@ describe('unified orders API contract', () => { it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => { vi.mocked(http.get).mockResolvedValue({ data: { - records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', measurement: null, preload: null, loading: null, delivery: null, transport: null }], + records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', customerRemark: '客户备注', latestInternalNote: '最新内部备注', measurement: null, preload: null, loading: null, delivery: null, transport: null }], page: 1, size: 20, total: 1, pages: 1, } }) await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({ - records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3 }], + records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, customerRemark: '客户备注', latestInternalNote: '最新内部备注' }], }) }) @@ -72,14 +72,11 @@ describe('unified orders API contract', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } }) }) - it('uses the explicit first-leg carrier and approval boundaries for pending review', async () => { - vi.mocked(http.put).mockResolvedValue({ data: null }) + it('uses the explicit first-leg approval boundary for pending review', async () => { vi.mocked(http.post).mockResolvedValue({ data: null }) - await assignPlatformUnifiedOrderCarrier('208', '90001', '30001') await approvePlatformUnifiedOrder('208', '90001') - expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001/carrier', { carrierId: '30001' }, { params: { tenantId: '208' } }) expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '208' } }) }) @@ -88,4 +85,10 @@ describe('unified orders API contract', () => { await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001') expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } }) }) + + it('loads fee detail rows from the inbound fee-detail contract', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [{ id: '1', inboundId: '88', reviewStatus: 'APPROVED', amount: 100 }] }) + await expect(listPlatformUnifiedOrderFeeDetails('208', '88')).resolves.toHaveLength(1) + expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details', { params: { accountId: '208' } }) + }) }) diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts index dd7fc731..8ffdbe29 100644 --- a/src/modules/unified-orders/api/unifiedOrders.ts +++ b/src/modules/unified-orders/api/unifiedOrders.ts @@ -124,6 +124,7 @@ export interface UnifiedOrderResource { expressTrackingNo?: string | null remark?: string | null customerRemark?: string | null + latestInternalNote?: string | null internalRemark?: string | null createdBy?: string | null } @@ -208,6 +209,36 @@ export interface UnifiedOrderDetailResource { timeline: UnifiedOrderTimelineEvent[] } +export interface UnifiedOrderFeeDetailResource { + id: number | string + inboundId: number | string + inboundCode: string | null + reviewStatus: string | null + direction: string | null + customerName: string | null + invoiceTitle: string | null + businessDate: string | null + channelName: string | null + destinationName: string | null + fbaNo?: string | null + packageCount: number | null + actualGrossWeightKg: number | null + actualVolumeCbm: number | null + feeTypeNameSnapshot: string | null + currency: string | null + amount: number | null + chargeableQuantity: number | null + chargeUnit: string | null + unitPrice: number | null + inboundedAt: string | null + outboundAt?: string | null + storageDays?: number | null + settlementIncluded: boolean | null + billNo?: string | null + settlementStartDate?: string | null + settlementEndDate?: string | null +} + export interface UnifiedOrderPageResource { records: UnifiedOrderResource[] page: number @@ -327,14 +358,6 @@ export async function getPlatformUnifiedOrder(tenantId: string, orderId: string) return { ...result, summary: normalizeRow(result.summary) } } -export function assignPlatformUnifiedOrderCarrier(tenantId: string, orderId: string, carrierId: string) { - return http.put, ApiEnvelope>( - `/platform/v1/orders/first-leg/${orderId}/carrier`, - { carrierId }, - { params: { tenantId } }, - ).then(unwrapApiResponse) -} - export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) { return http.post, ApiEnvelope>( `/platform/v1/orders/first-leg/${orderId}/approve`, @@ -351,6 +374,14 @@ export function addPlatformUnifiedOrderNote(tenantId: string, orderId: string, c ).then(unwrapApiResponse) } +export async function listPlatformUnifiedOrderFeeDetails(accountId: string, inboundId: string) { + const response = await http.get, ApiEnvelope>( + `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details`, + { params: { accountId } }, + ) + return unwrapApiResponse(response) +} + export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) { return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status } diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 097cd7f9..15bb68d6 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -59,6 +59,8 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('slotName: \'orderStatus\'') expect(source).toContain('slotName: \'measurementReviewStatus\'') expect(source).toContain("NOT_SUBMITTED: '未提交'") + expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '备注'") + expect(source).toContain("{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '客服内部备注'") }) it('keeps only confirmed business actions as explicit boundaries', () => { @@ -72,7 +74,7 @@ describe('UnifiedOrdersView contract', () => { expect(source).not.toContain('/actions/') }) - it('approves pending review orders without requiring a carrier assignment', () => { + it('approves pending review orders without exposing an order carrier assignment', () => { expect(source).toContain("row.nextAction === 'APPROVE_ORDER'") expect(source).toContain("can('platform:order:approve')") expect(source).toContain('await approvePlatformUnifiedOrder(tenantId, orderId)') @@ -80,8 +82,8 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('confirm-text="审核通过"') expect(source).not.toContain('assignPlatformUnifiedOrderCarrier') expect(source).not.toContain('listPlatformPartners') - expect(source).not.toContain('请选择启用的承运商') - expect(source).toContain("detail.order.order?.carrierName || '待平台指派'") + expect(source).not.toContain('指派承运商') + expect(source).not.toContain('待平台指派') }) it('provides unified detail, history and internal note states', () => { @@ -89,9 +91,22 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('aria-label="状态历史"') expect(source).toContain('aria-label="内部备注"') expect(source).toContain('订单详情暂时无法加载') + expect(source).toContain('一、客户下单数据') + expect(source).toContain('二、外箱规数据') + expect(source).toContain('三、实际测量数据') + expect(source).toContain('measurementCollapsed = reactive({ customer: false, outer: false, actual: false })') + expect(source).toContain('function toggleMeasurementSection(section: keyof typeof measurementCollapsed)') + expect(source).toContain(':aria-expanded="!measurementCollapsed.customer"') + expect(source).toContain(':aria-expanded="!measurementCollapsed.outer"') + expect(source).toContain(':aria-expanded="!measurementCollapsed.actual"') + expect(source).toContain('class="measurement-section-summary"') + expect(source).toContain('') + expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }") expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)') expect(source).toContain('aria-label="更多订单操作"') expect(source).toContain("can('platform:order:note')") + expect(source).toContain('await loadOrders()') }) it('renders order detail as a routed full page instead of a drawer', () => { @@ -110,6 +125,8 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain(`
${label}
`) } expect(source).toContain("partyValue(detail.order.parties, 'SENDER', 'phone')") + expect(source).toContain("partyValue(detail.order.parties, 'RECEIVER', 'phone')") + expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'") expect(source).toContain("cargoTotal(detail.order.cargoes, 'netWeightKg')") expect(source).toContain('暂无费用数据') }) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 9b162ef0..b742fa9d 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -1,5 +1,5 @@ @@ -613,7 +780,7 @@ onMounted(() => {
- +
@@ -633,11 +800,9 @@ onMounted(() => {
目的仓联系人
{{ detail.summary.destinationContact || '—' }}
目的仓联系人电话
{{ detail.summary.destinationContactPhone || '—' }}
渠道
{{ detail.summary.channelName || '—' }}
-
承运商
{{ detail.order.order?.carrierName || '待平台指派' }}
-
业务类型
{{ detail.order.order?.tradeTerm || '—' }}
-
贸易方式
{{ detail.order.order?.tradeTerm || detail.summary.businessType || '—' }}
+
业务类型
{{ detail.summary.businessType || '—' }}
+
贸易条款
{{ detail.order.order?.tradeTerm || '—' }}
买单 / 退税
{{ detail.order.order?.taxMode || '—' }}
-
报关方式
{{ detail.summary.businessType || '—' }}
费用审核状态
{{ detail.summary.feeReviewStatus || '—' }}
是否扣货
{{ displayBoolean(detail.summary.holdCargo) }}
扣货理由
{{ detail.summary.holdReason || '—' }}
@@ -671,9 +836,9 @@ onMounted(() => {
测量完成时间
{{ formatDate(detail.summary.measurementCompletedAt || detail.summary.measurement?.completedAt) }}
提货联系人
{{ partyValue(detail.order.parties, 'SENDER', 'contactName') }} · {{ detail.summary.pickupContactPhone || partyValue(detail.order.parties, 'SENDER', 'phone') }} · {{ detail.summary.pickupContactEmail || partyValue(detail.order.parties, 'SENDER', 'email') }}
提货地址
{{ detail.summary.pickupAddress || partyAddress(detail.order.parties, 'SENDER') }} · {{ detail.summary.pickupPostalCode || partyValue(detail.order.parties, 'SENDER', 'postalCode') }}
-
目的地联系人
{{ partyValue(detail.order.parties, 'CONSIGNEE', 'contactName') }} · {{ partyValue(detail.order.parties, 'CONSIGNEE', 'phone') }} · {{ partyValue(detail.order.parties, 'CONSIGNEE', 'email') }}
-
目的仓联系人邮箱
{{ partyValue(detail.order.parties, 'CONSIGNEE', 'email') }}
-
目的地地址
{{ partyAddress(detail.order.parties, 'CONSIGNEE') }} · {{ partyValue(detail.order.parties, 'CONSIGNEE', 'postalCode') }}
+
目的地联系人
{{ partyValue(detail.order.parties, 'RECEIVER', 'contactName') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'phone') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}
+
目的仓联系人邮箱
{{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}
+
目的地地址
{{ partyAddress(detail.order.parties, 'RECEIVER') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'postalCode') }}
备注
{{ detail.order.order?.customerRemark || '—' }}
关联文件
{{ detail.summary.associatedFiles?.join('、') || '—' }}
唛头文件
{{ detail.summary.markFiles?.join('、') || '—' }}
@@ -690,13 +855,56 @@ onMounted(() => {

货品信息

共 {{ detail.order.cargoes.length }} 条

- + + + + + + + + + + + + + + +