优化
This commit is contained in:
+57
-7
@@ -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<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: refreshToken.value },
|
||||
)
|
||||
const result = unwrapApiResponse<LoginResponse>(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<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: currentRefreshToken },
|
||||
)
|
||||
ensureCurrentGeneration(expectedGeneration)
|
||||
if (!localStorage.getItem(REFRESH_TOKEN_KEY)) throw new SupersededSessionError('登录会话已在其他标签页退出')
|
||||
await applySession(unwrapApiResponse<LoginResponse>(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
|
||||
|
||||
@@ -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<string>) => {
|
||||
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<string>) => 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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => Promise<T>
|
||||
refresh: (refreshToken: string) => Promise<T>
|
||||
}
|
||||
|
||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||
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)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
<div class="form-section"><h3>联系与商务</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入手机号或固定电话" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="联系电话(登录用户名)" prop="contactPhone" required><el-input v-model="form.contactPhone" :disabled="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" placeholder="请输入手机号或固定电话" maxlength="32" /><small v-if="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" class="field-hint">客户已开户,联系电话作为登录用户名不可修改</small></el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
|
||||
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
|
||||
@@ -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; }
|
||||
|
||||
@@ -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: <T>(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')
|
||||
})
|
||||
})
|
||||
@@ -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<PlatformCarrierRateResource,
|
||||
'id' | 'tenantId' | 'originWarehouseId' | 'carrierId' | 'createdBy' | 'updatedBy'> {
|
||||
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<PlatformCarrierRatePayload[]>, ApiEnvelope<PlatformCarrierRatePayload[]>>(
|
||||
basePath,
|
||||
{ params: rateCode ? { rateCode } : {} },
|
||||
)
|
||||
return unwrapApiResponse(response).map(normalizeCarrierRate)
|
||||
}
|
||||
|
||||
export async function getPlatformCarrierRate(id: string) {
|
||||
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
|
||||
`${basePath}/${id}`,
|
||||
)
|
||||
return normalizeCarrierRate(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createPlatformCarrierRateVersion(
|
||||
payload: CreatePlatformCarrierRateVersionRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
|
||||
`${basePath}/versions`,
|
||||
payload,
|
||||
)
|
||||
return normalizeCarrierRate(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function retirePlatformCarrierRate(id: string) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -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: <T>(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' },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<PlatformPriceBookVersion,
|
||||
'id' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy'> {
|
||||
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<PlatformPriceBookParsedChannel,
|
||||
'estimatedDepartureDate' | 'estimatedDepartureText' | 'latestWarehouseEntry' | 'vesselName' | 'voyageNo'
|
||||
| 'referenceTransit' | 'surchargeDescription' | 'remark'> {
|
||||
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<PlatformPriceBookParsedRate,
|
||||
'id' | 'originWarehouseId' | 'destinationWarehouseId' | 'weightFromKg' | 'unitPrice' | 'sourceRowNumber'> {
|
||||
id?: ApiId | null
|
||||
originWarehouseId?: ApiId | null
|
||||
destinationWarehouseId?: ApiId | null
|
||||
weightFromKg?: ApiNumber | null
|
||||
unitPrice: ApiNumber
|
||||
sourceRowNumber: ApiNumber
|
||||
}
|
||||
|
||||
interface PriceBookParseIssuePayload extends Omit<PlatformPriceBookParseIssue, 'sourceRowNumber'> {
|
||||
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<PlatformPriceBookVersion[]> {
|
||||
const status = query.status?.trim()
|
||||
const response = await http.get<ApiEnvelope<PriceBookVersionPayload[]>, ApiEnvelope<PriceBookVersionPayload[]>>(basePath, {
|
||||
params: status ? { status } : {},
|
||||
})
|
||||
return unwrapApiResponse(response).map(normalizeVersion)
|
||||
}
|
||||
|
||||
export async function getPlatformPriceBook(versionId: string): Promise<PlatformPriceBookDetail> {
|
||||
const response = await http.get<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(
|
||||
`${basePath}/${encodeURIComponent(versionId)}`,
|
||||
)
|
||||
return normalizeDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function uploadPlatformPriceBook(file: File): Promise<PlatformPriceBookDetail> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(basePath, form)
|
||||
return normalizeDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function publishPlatformPriceBook(
|
||||
versionId: string,
|
||||
request: PriceBookConfirmationRequest,
|
||||
): Promise<PlatformPriceBookDetail> {
|
||||
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(
|
||||
`${basePath}/${encodeURIComponent(versionId)}/publish`,
|
||||
request,
|
||||
)
|
||||
return normalizeDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function downloadPlatformPriceBookSource(versionId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(versionId)}/source`, { responseType: 'blob' })
|
||||
}
|
||||
@@ -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('<dt>适用范围</dt><dd>全部客户</dd>')
|
||||
it('shows parsing blockers and a channel-tabbed, read-only preview', () => {
|
||||
expect(source).toContain('nonMappingBlockingIssues')
|
||||
expect(source).toContain('解析与校验')
|
||||
expect(source).toContain('按渠道预览')
|
||||
expect(source).toContain('<el-tabs v-model="selectedChannelCode"')
|
||||
expect(source).toContain('sourceLocation')
|
||||
expect(source).toContain('pricingTaxModeLabel')
|
||||
})
|
||||
|
||||
it('loads shared carrier master data under the platform public scope', () => {
|
||||
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('<StatusBadge :status="statusLabel(')
|
||||
expect(source).toContain('<template #empty><el-empty')
|
||||
expect(source).toContain('<div class="business-page">')
|
||||
expect(source).not.toContain('<QueryToolbar v-model="filters" class="surface-panel"')
|
||||
expect(source).not.toContain('<template #pagination-left>')
|
||||
expect(source).not.toContain('<el-tag')
|
||||
})
|
||||
|
||||
it('does not create route or navigation ownership in the page task', () => {
|
||||
expect(source).not.toContain('createRouter')
|
||||
expect(source).not.toContain('adminNavigation')
|
||||
for (const unsupportedCapability of ['导入', '导出', '全部船期', '参考时效', '最晚进仓时间']) {
|
||||
expect(source).not.toContain(unsupportedCapability)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -326,6 +326,14 @@ export function getPlatformTenant(tenantId: number | string) {
|
||||
return getData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`)
|
||||
}
|
||||
|
||||
export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) {
|
||||
const response = await http.get<ApiEnvelope<boolean>, ApiEnvelope<boolean>>(
|
||||
'/platform/v1/iam/tenants/contact-phone-availability',
|
||||
{ params: { contactPhone, ...(excludeTenantId ? { excludeTenantId } : {}) } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function createPlatformTenant(payload: CreatePlatformTenantRequest) {
|
||||
return postData<PlatformTenantResource>('/platform/v1/iam/tenants', payload)
|
||||
}
|
||||
|
||||
@@ -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(['—', '—'])
|
||||
|
||||
@@ -320,10 +320,10 @@ defineExpose({ reload: loadFee })
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-labelledby="rate-title">
|
||||
<div class="section-title"><div><h4 id="rate-title">船司 FBA 价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
|
||||
<div class="section-title"><div><h4 id="rate-title">渠道价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
|
||||
<div><dt>渠道 / 船司</dt><dd>{{ snapshot.channelCode }} / {{ snapshot.carrierName }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ snapshot.channelCode }}</dd></div>
|
||||
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div>
|
||||
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { getInboundMeasurement } from './measurement/api'
|
||||
export { downloadWeightImage } from './weight/api/weightOcr'
|
||||
export type { InboundMeasurementDetail } from './measurement/types'
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void>, ApiEnvelope<void>>(
|
||||
`/platform/v1/orders/first-leg/${orderId}/carrier`,
|
||||
{ carrierId },
|
||||
{ params: { tenantId } },
|
||||
).then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) {
|
||||
return http.post<ApiEnvelope<void>, ApiEnvelope<void>>(
|
||||
`/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<UnifiedOrderFeeDetailResource[]>, ApiEnvelope<UnifiedOrderFeeDetailResource[]>>(
|
||||
`/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
|
||||
}
|
||||
|
||||
@@ -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('<ChevronUp v-if="!measurementCollapsed.customer"')
|
||||
expect(source).toContain('<ChevronDown v-else :size="18" />')
|
||||
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(`<dt>${label}</dt>`)
|
||||
}
|
||||
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('暂无费用数据')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, RefreshCw, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
getPlatformUnifiedOrder,
|
||||
approvePlatformUnifiedOrder,
|
||||
addPlatformUnifiedOrderNote,
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
listPlatformUnifiedOrders,
|
||||
unifiedOrderStatusOptions,
|
||||
type UnifiedOrderDetailResource,
|
||||
type UnifiedOrderMainStatus,
|
||||
type UnifiedOrderResource,
|
||||
type UnifiedOrderFeeDetailResource,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -23,6 +25,11 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import {
|
||||
downloadWeightImage,
|
||||
getInboundMeasurement,
|
||||
type InboundMeasurementDetail,
|
||||
} from '@/modules/inbounds'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
tenantId: string
|
||||
@@ -74,6 +81,15 @@ const noteOpen = ref(false)
|
||||
const noteSaving = ref(false)
|
||||
const noteContent = ref('')
|
||||
const noteOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const feeDetails = ref<UnifiedOrderFeeDetailResource[]>([])
|
||||
const feeLoading = ref(false)
|
||||
const feeLoadedKey = ref('')
|
||||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||||
const measurementLoading = ref(false)
|
||||
const measurementLoadedKey = ref('')
|
||||
const measurementImageUrls = ref<string[]>([])
|
||||
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
||||
let detailLoadingKey = ''
|
||||
const showAdvancedFilters = ref(false)
|
||||
const filters = ref<FilterModel>(emptyFilters())
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
@@ -87,6 +103,16 @@ const detailTabs = [
|
||||
{ key: 'other', label: '其他', icon: StickyNote },
|
||||
]
|
||||
|
||||
const customerMeasurementColumns = [
|
||||
{ key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' },
|
||||
{ key: 'heightCm', label: '箱规高(cm)' }, { key: 'packageCount', label: '箱数' }, { key: 'unitWeightKg', label: '每箱毛重' },
|
||||
{ key: 'unitVolumeCbm', label: '每箱体积' }, { key: 'remark', label: '备注' },
|
||||
]
|
||||
const measuredColumns = [
|
||||
...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' },
|
||||
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' }, { key: 'remark', label: '备注' },
|
||||
]
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ value: '' as const, label: '全部' },
|
||||
...unifiedOrderStatusOptions,
|
||||
@@ -200,8 +226,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'pcOrderFiles', prop: 'pcOrderFiles', label: 'PC端下单文件', minWidth: 140, slotName: 'field' },
|
||||
{ key: 'expressCompany', prop: 'expressCompany', label: '快递公司', minWidth: 130, slotName: 'field' },
|
||||
{ key: 'expressTrackingNo', prop: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'field' },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'internalRemark', prop: 'internalRemark', label: '客服内部备注', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'customerRemark', prop: 'customerRemark', label: '备注', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '客服内部备注', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'createdBy', prop: 'createdBy', label: '创建人', minWidth: 120, slotName: 'field' },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建日期', minWidth: 150, slotName: 'field' },
|
||||
]
|
||||
@@ -259,13 +285,13 @@ function formatNumber(value: number | null | undefined, digits = 2) {
|
||||
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
||||
}
|
||||
|
||||
function partyValue(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'CONSIGNEE', key: string) {
|
||||
function partyValue(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER', key: string) {
|
||||
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
|
||||
const value = party?.[key]
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function partyAddress(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'CONSIGNEE') {
|
||||
function partyAddress(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER') {
|
||||
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
|
||||
if (!party) return '—'
|
||||
return ['province', 'city', 'district', 'addressLine'].map((key) => party[key]).filter((value) => typeof value === 'string' && value.trim()).join(' ') || '—'
|
||||
@@ -336,6 +362,138 @@ function orderStatusLabel(value: string | null | undefined) {
|
||||
} as Record<string, string>)[value || ''] || value || '—'
|
||||
}
|
||||
|
||||
function cargoValue(cargo: Record<string, unknown>, key: string) {
|
||||
const value = cargo[key]
|
||||
if (key === 'circumferenceCm') {
|
||||
const width = Number(cargo.widthCm)
|
||||
const height = Number(cargo.heightCm)
|
||||
return Number.isFinite(width) && Number.isFinite(height) ? formatNumber(2 * (width + height)) : '—'
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'number') return formatNumber(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function cargoImage(cargo: Record<string, unknown>) {
|
||||
const value = cargo.imageUrl || cargo.cargoImageUrl || cargo.image
|
||||
return typeof value === 'string' && value ? value : ''
|
||||
}
|
||||
|
||||
function feeDate(value: string | null | undefined) {
|
||||
return value ? formatDate(value) : '—'
|
||||
}
|
||||
|
||||
function feeText(value: string | null | undefined) {
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
function feeNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : formatNumber(value, 4)
|
||||
}
|
||||
|
||||
function feeReviewLabel(value: string | null | undefined) {
|
||||
return ({ PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回', NOT_SUBMITTED: '未提交' } as Record<string, string>)[value || ''] || value || '—'
|
||||
}
|
||||
|
||||
function feeDirectionLabel(value: string | null | undefined) {
|
||||
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value || ''] || value || '—'
|
||||
}
|
||||
|
||||
async function loadFeeDetails() {
|
||||
const summary = detail.value?.summary
|
||||
if (!summary?.inboundId || !can('firstleg:inbound:fee-detail:view')) return
|
||||
const key = `${summary.tenantId}:${summary.inboundId}`
|
||||
if (feeLoadedKey.value === key || feeLoading.value) return
|
||||
feeLoading.value = true
|
||||
try {
|
||||
feeDetails.value = await listPlatformUnifiedOrderFeeDetails(String(summary.tenantId), String(summary.inboundId))
|
||||
feeLoadedKey.value = key
|
||||
} catch {
|
||||
feeDetails.value = []
|
||||
} finally {
|
||||
feeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false) {
|
||||
if (customerSource && key === 'unitWeightKg') {
|
||||
const total = Number(row.grossWeightKg)
|
||||
const count = Number(row.packageCount)
|
||||
return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 4) : '—'
|
||||
}
|
||||
if (customerSource && key === 'unitVolumeCbm') {
|
||||
const total = Number(row.volumeCbm)
|
||||
const count = Number(row.packageCount)
|
||||
return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 6) : '—'
|
||||
}
|
||||
if (customerSource && key === 'remark') return String(row.cargoRemark || '—')
|
||||
const value = row[key]
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
|
||||
}
|
||||
|
||||
interface MeasurementTotals {
|
||||
count: number
|
||||
gross: number
|
||||
volume: number
|
||||
volumetric: number
|
||||
}
|
||||
|
||||
function measurementTotals(rows: Record<string, unknown>[], customerSource = false) {
|
||||
const values = rows.reduce<MeasurementTotals>((result, row) => {
|
||||
const count = Number(row.packageCount)
|
||||
const gross = Number(customerSource ? row.grossWeightKg : row.totalWeightKg)
|
||||
const volume = Number(customerSource ? row.volumeCbm : row.totalVolumeCbm)
|
||||
const volumetric = Number(row.volumeWeightKg)
|
||||
if (Number.isFinite(count)) result.count += count
|
||||
if (Number.isFinite(gross)) result.gross += gross
|
||||
if (Number.isFinite(volume)) result.volume += volume
|
||||
if (Number.isFinite(volumetric)) result.volumetric += volumetric
|
||||
return result
|
||||
}, { count: 0, gross: 0, volume: 0, volumetric: 0 })
|
||||
return {
|
||||
count: values.count || null, gross: values.gross || null, volume: values.volume || null,
|
||||
volumetric: values.volumetric || null, weightVolume: values.volume || null,
|
||||
}
|
||||
}
|
||||
|
||||
function revokeMeasurementImages() {
|
||||
measurementImageUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||
measurementImageUrls.value = []
|
||||
}
|
||||
|
||||
async function loadMeasurementDetails() {
|
||||
const summary = detail.value?.summary
|
||||
if (!summary?.inboundId || !can('platform:firstleg:inbound:view')) return
|
||||
const key = `${summary.tenantId}:${summary.inboundId}`
|
||||
if (measurementLoadedKey.value === key || measurementLoading.value) return
|
||||
measurementLoading.value = true
|
||||
try {
|
||||
measurementDetail.value = await getInboundMeasurement(String(summary.tenantId), String(summary.inboundId))
|
||||
measurementLoadedKey.value = key
|
||||
revokeMeasurementImages()
|
||||
const fileIds = [...measurementDetail.value.items.map((item) => item.intelligentImageFileId), ...measurementDetail.value.weightResults.map((item) => item.fileId)]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
const blobs = await Promise.all(fileIds.map((fileId) => downloadWeightImage(String(summary.tenantId), String(summary.inboundId), fileId).catch(() => null)))
|
||||
measurementImageUrls.value = blobs.filter((blob): blob is Blob => Boolean(blob)).map((blob) => URL.createObjectURL(blob))
|
||||
} catch {
|
||||
measurementDetail.value = null
|
||||
revokeMeasurementImages()
|
||||
} finally {
|
||||
measurementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectDetailTab(key: string) {
|
||||
activeDetailTab.value = key
|
||||
if (key === 'fees') void loadFeeDetails()
|
||||
if (key === 'measurement') void loadMeasurementDetails()
|
||||
}
|
||||
|
||||
function toggleMeasurementSection(section: keyof typeof measurementCollapsed) {
|
||||
measurementCollapsed[section] = !measurementCollapsed[section]
|
||||
}
|
||||
|
||||
function nextActionBoundary(row: UnifiedOrderResource) {
|
||||
return row.nextAction === 'CONFIRM_MEASUREMENT' ? '待完成测量' : ''
|
||||
}
|
||||
@@ -411,8 +569,8 @@ async function saveNote() {
|
||||
const target = noteOrder.value
|
||||
await addPlatformUnifiedOrderNote(String(target.tenantId), String(target.orderId), content)
|
||||
noteOpen.value = false
|
||||
if (detail.value?.summary.orderId === target.orderId) await loadDetail()
|
||||
else await loadOrders()
|
||||
await loadOrders()
|
||||
if (detail.value?.summary.orderId === target.orderId) await loadDetail(true)
|
||||
noteOrder.value = null
|
||||
ElMessage.success('内部备注已保存')
|
||||
} catch (error) {
|
||||
@@ -545,13 +703,21 @@ function closeDetail() {
|
||||
void router.push({ name: 'unified-orders' })
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
async function loadDetail(force = false) {
|
||||
if (!orderId.value) return
|
||||
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
|
||||
if (!tenantId) {
|
||||
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
|
||||
return
|
||||
}
|
||||
const requestKey = `${tenantId}:${orderId.value}`
|
||||
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
|
||||
detailLoadingKey = requestKey
|
||||
feeDetails.value = []
|
||||
feeLoadedKey.value = ''
|
||||
measurementDetail.value = null
|
||||
measurementLoadedKey.value = ''
|
||||
revokeMeasurementImages()
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
try {
|
||||
@@ -561,6 +727,7 @@ async function loadDetail() {
|
||||
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
if (detailLoadingKey === requestKey) detailLoadingKey = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +741,7 @@ watch(orderId, (value) => {
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadWarehouses()
|
||||
void loadOrders()
|
||||
if (!orderId.value) void loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -613,7 +780,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<div class="detail-tabs" role="tablist" aria-label="订单详情模块">
|
||||
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="activeDetailTab = tab.key"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
|
||||
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
|
||||
</div>
|
||||
|
||||
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
|
||||
@@ -633,11 +800,9 @@ onMounted(() => {
|
||||
<div><dt>目的仓联系人</dt><dd>{{ detail.summary.destinationContact || '—' }}</dd></div>
|
||||
<div><dt>目的仓联系人电话</dt><dd>{{ detail.summary.destinationContactPhone || '—' }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
|
||||
<div><dt>承运商</dt><dd>{{ detail.order.order?.carrierName || '待平台指派' }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ detail.order.order?.tradeTerm || '—' }}</dd></div>
|
||||
<div><dt>贸易方式</dt><dd>{{ detail.order.order?.tradeTerm || detail.summary.businessType || '—' }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ detail.summary.businessType || '—' }}</dd></div>
|
||||
<div><dt>贸易条款</dt><dd>{{ detail.order.order?.tradeTerm || '—' }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ detail.order.order?.taxMode || '—' }}</dd></div>
|
||||
<div><dt>报关方式</dt><dd>{{ detail.summary.businessType || '—' }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ detail.summary.feeReviewStatus || '—' }}</dd></div>
|
||||
<div><dt>是否扣货</dt><dd>{{ displayBoolean(detail.summary.holdCargo) }}</dd></div>
|
||||
<div><dt>扣货理由</dt><dd>{{ detail.summary.holdReason || '—' }}</dd></div>
|
||||
@@ -671,9 +836,9 @@ onMounted(() => {
|
||||
<div><dt>测量完成时间</dt><dd>{{ formatDate(detail.summary.measurementCompletedAt || detail.summary.measurement?.completedAt) }}</dd></div>
|
||||
<div class="wide"><dt>提货联系人</dt><dd>{{ partyValue(detail.order.parties, 'SENDER', 'contactName') }} · {{ detail.summary.pickupContactPhone || partyValue(detail.order.parties, 'SENDER', 'phone') }} · {{ detail.summary.pickupContactEmail || partyValue(detail.order.parties, 'SENDER', 'email') }}</dd></div>
|
||||
<div class="wide"><dt>提货地址</dt><dd>{{ detail.summary.pickupAddress || partyAddress(detail.order.parties, 'SENDER') }} · {{ detail.summary.pickupPostalCode || partyValue(detail.order.parties, 'SENDER', 'postalCode') }}</dd></div>
|
||||
<div class="wide"><dt>目的地联系人</dt><dd>{{ partyValue(detail.order.parties, 'CONSIGNEE', 'contactName') }} · {{ partyValue(detail.order.parties, 'CONSIGNEE', 'phone') }} · {{ partyValue(detail.order.parties, 'CONSIGNEE', 'email') }}</dd></div>
|
||||
<div><dt>目的仓联系人邮箱</dt><dd>{{ partyValue(detail.order.parties, 'CONSIGNEE', 'email') }}</dd></div>
|
||||
<div class="wide"><dt>目的地地址</dt><dd>{{ partyAddress(detail.order.parties, 'CONSIGNEE') }} · {{ partyValue(detail.order.parties, 'CONSIGNEE', 'postalCode') }}</dd></div>
|
||||
<div class="wide"><dt>目的地联系人</dt><dd>{{ partyValue(detail.order.parties, 'RECEIVER', 'contactName') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'phone') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}</dd></div>
|
||||
<div><dt>目的仓联系人邮箱</dt><dd>{{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}</dd></div>
|
||||
<div class="wide"><dt>目的地地址</dt><dd>{{ partyAddress(detail.order.parties, 'RECEIVER') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'postalCode') }}</dd></div>
|
||||
<div class="wide"><dt>备注</dt><dd>{{ detail.order.order?.customerRemark || '—' }}</dd></div>
|
||||
<div class="wide"><dt>关联文件</dt><dd>{{ detail.summary.associatedFiles?.join('、') || '—' }}</dd></div>
|
||||
<div class="wide"><dt>唛头文件</dt><dd>{{ detail.summary.markFiles?.join('、') || '—' }}</dd></div>
|
||||
@@ -690,13 +855,56 @@ onMounted(() => {
|
||||
<div class="panel-title"><div><h2>货品信息</h2><p>共 {{ detail.order.cargoes.length }} 条</p></div><PackageOpen :size="19" /></div>
|
||||
<div class="detail-table-wrap">
|
||||
<el-table :data="detail.order.cargoes" empty-text="暂无货品信息">
|
||||
<el-table-column prop="itemNo" label="#" width="64" /><el-table-column prop="cargoName" label="品名" min-width="170" /><el-table-column prop="packageType" label="包装" min-width="110" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="grossWeightKg" label="毛重(kg)" min-width="110" /><el-table-column prop="volumeCbm" label="体积(m³)" min-width="110" />
|
||||
<el-table-column prop="itemNo" label="#" width="64" />
|
||||
<el-table-column label="报关HS_CODE" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'hsCode') }}</template></el-table-column>
|
||||
<el-table-column label="货品图片" width="110"><template #default="{ row }"><el-image v-if="cargoImage(row as Record<string, unknown>)" :src="cargoImage(row as Record<string, unknown>)" fit="contain" class="cargo-image" /><span v-else>—</span></template></el-table-column>
|
||||
<el-table-column label="SKU NO." min-width="150"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'skuNo') }}</template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="170"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'cargoName') }}</template></el-table-column>
|
||||
<el-table-column label="英文品名" min-width="170"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'cargoNameEn') }}</template></el-table-column>
|
||||
<el-table-column label="清关HS_CODE" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'customsHsCode') }}</template></el-table-column>
|
||||
<el-table-column label="箱规长(cm)" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'lengthCm') }}</template></el-table-column>
|
||||
<el-table-column label="箱规宽(cm)" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'widthCm') }}</template></el-table-column>
|
||||
<el-table-column label="箱规高(cm)" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'heightCm') }}</template></el-table-column>
|
||||
<el-table-column label="箱规周长(cm)" min-width="120"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'circumferenceCm') }}</template></el-table-column>
|
||||
<el-table-column label="每箱产品数量" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'unitsPerPackage') }}</template></el-table-column>
|
||||
<el-table-column label="箱数" width="90"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'packageCount') }}</template></el-table-column>
|
||||
<el-table-column label="总毛重(T.KGW)" min-width="140"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'grossWeightKg') }}</template></el-table-column>
|
||||
<el-table-column label="总净重(T.KNW)" min-width="140"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'netWeightKg') }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'fees'">
|
||||
<div class="panel-title"><div><h2>费用信息</h2><p>订单费用与审核结果</p></div><WalletCards :size="19" /></div><el-empty description="暂无费用数据" :image-size="64" />
|
||||
<div class="panel-title"><div><h2>费用信息</h2><p>共 {{ feeDetails.length }} 条应收应付费用明细</p></div><WalletCards :size="19" /></div>
|
||||
<el-alert v-if="!can('firstleg:inbound:fee-detail:view')" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||||
<div v-else v-loading="feeLoading" class="detail-table-wrap">
|
||||
<el-table :data="feeDetails" empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="审核状态" min-width="120"><template #default="{ row }"><StatusBadge :status="feeReviewLabel((row as UnifiedOrderFeeDetailResource).reviewStatus)" /></template></el-table-column>
|
||||
<el-table-column label="应收/应付" min-width="110"><template #default="{ row }">{{ feeDirectionLabel((row as UnifiedOrderFeeDetailResource).direction) }}</template></el-table-column>
|
||||
<el-table-column label="客户公司名" min-width="170"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).customerName) }}</template></el-table-column>
|
||||
<el-table-column label="裹箱头" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).invoiceTitle) }}</template></el-table-column>
|
||||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).inboundCode) }}</template></el-table-column>
|
||||
<el-table-column label="业务发生日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).businessDate) }}</template></el-table-column>
|
||||
<el-table-column label="渠道" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).channelName) }}</template></el-table-column>
|
||||
<el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).destinationName) }}</template></el-table-column>
|
||||
<el-table-column label="FBA编号" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).fbaNo) }}</template></el-table-column>
|
||||
<el-table-column label="箱数" width="90"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).packageCount) }}</template></el-table-column>
|
||||
<el-table-column label="毛重" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualGrossWeightKg) }}</template></el-table-column>
|
||||
<el-table-column label="体积" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualVolumeCbm) }}</template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).feeTypeNameSnapshot) }}</template></el-table-column>
|
||||
<el-table-column label="货币" width="90"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).currency) }}</template></el-table-column>
|
||||
<el-table-column label="应收金额" min-width="120"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).amount) }}</template></el-table-column>
|
||||
<el-table-column label="计费体称/重量" min-width="150"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).chargeableQuantity) }} {{ (row as UnifiedOrderFeeDetailResource).chargeUnit || '' }}</template></el-table-column>
|
||||
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).unitPrice) }}</template></el-table-column>
|
||||
<el-table-column label="入仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).inboundedAt) }}</template></el-table-column>
|
||||
<el-table-column label="出仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).outboundAt) }}</template></el-table-column>
|
||||
<el-table-column label="仓储天数" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).storageDays) }}</template></el-table-column>
|
||||
<el-table-column label="参与结算" min-width="110"><template #default="{ row }">{{ (row as UnifiedOrderFeeDetailResource).settlementIncluded == null ? '—' : (row as UnifiedOrderFeeDetailResource).settlementIncluded ? '是' : '否' }}</template></el-table-column>
|
||||
<el-table-column label="账单编号" min-width="140"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).billNo) }}</template></el-table-column>
|
||||
<el-table-column label="结算起始日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementStartDate) }}</template></el-table-column>
|
||||
<el-table-column label="结算结束日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementEndDate) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'history'">
|
||||
@@ -707,7 +915,28 @@ onMounted(() => {
|
||||
|
||||
<template v-else-if="activeDetailTab === 'measurement'">
|
||||
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
|
||||
<dl class="measurement-grid"><div><dt>审核状态</dt><dd>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</dd></div><div><dt>实测毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div><div><dt>实测体积</dt><dd>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div><div><dt>计费重量</dt><dd>{{ formatNumber(detail.summary.measurement?.chargeableWeightKg) }} kg</dd></div></dl>
|
||||
<section class="measurement-section">
|
||||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button>
|
||||
<div v-if="!measurementCollapsed.customer">
|
||||
<div class="detail-table-wrap"><el-table :data="detail.order.cargoes" empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }">{{ measurementValue(row as Record<string, unknown>, column.key, true) }}</template></el-table-column></el-table></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="measurement-section">
|
||||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button>
|
||||
<div v-if="!measurementCollapsed.outer">
|
||||
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" empty-text="暂无外箱规数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }">{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></el-table-column></el-table></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="measurement-section">
|
||||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积重:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span><span>重量体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button>
|
||||
<div v-if="!measurementCollapsed.actual">
|
||||
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" empty-text="暂无实际测量数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }">{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></el-table-column></el-table></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span></div>
|
||||
<div class="measurement-media"><h4>测量图片</h4><div v-if="measurementImageUrls.length" class="measurement-media-grid"><el-image v-for="url in measurementImageUrls" :key="url" :src="url" fit="contain" :preview-src-list="measurementImageUrls" class="measurement-media-image" /></div><el-empty v-else description="暂无测量图片" :image-size="48" /><h4>测量视频</h4><el-empty description="暂无测量视频" :image-size="48" /></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -797,6 +1026,7 @@ onMounted(() => {
|
||||
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
|
||||
</AppDialog>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -860,7 +1090,22 @@ onMounted(() => {
|
||||
.order-info-grid dd, .measurement-grid dd { min-width: 0; margin: 0; color: #25324a; font-size: 12px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.detail-table-wrap { overflow-x: auto; }
|
||||
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: #f8fafc; }
|
||||
.cargo-image { width: 72px; height: 56px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||||
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.measurement-section { margin-top: 18px; }
|
||||
.measurement-section-title { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 16px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 7px 7px 0 0; background: #f8fafc; color: var(--cc-text); font: inherit; text-align: left; cursor: pointer; }
|
||||
.measurement-section-title:hover { background: #f2f6fb; }
|
||||
.measurement-section-title:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
|
||||
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||
.measurement-section-summary { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 18px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.measurement-section-title > svg { flex: 0 0 auto; color: var(--cc-muted); }
|
||||
.measurement-total { display: flex; min-height: 42px; align-items: center; justify-content: flex-end; gap: 24px; padding: 9px 14px; border: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||
.measurement-total { border-top: 0; border-radius: 0 0 7px 7px; }
|
||||
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
|
||||
.measurement-media { margin-top: 20px; }
|
||||
.measurement-media h4 { margin: 18px 0 10px; color: var(--cc-text); font-size: 14px; }
|
||||
.measurement-media-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.measurement-media-image { width: 110px; height: 90px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||||
.history-list, .notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-list li { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 12px; padding-bottom: 20px; }
|
||||
.history-list li:not(:last-child)::after { position: absolute; top: 15px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); content: ''; }
|
||||
@@ -875,6 +1120,6 @@ onMounted(() => {
|
||||
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||||
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-overview article:nth-child(2) { border-right: 0; } .detail-overview article:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs { grid-template-columns: repeat(6, 132px); } .detail-panel { padding: 16px; } }
|
||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs { grid-template-columns: repeat(6, 132px); } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
||||
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -98,7 +98,7 @@ watch(() => props.items.map((item) => item.key), syncSortableOrder)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-preference-list { display: grid; max-height: min(360px, calc(100vh - 180px)); max-height: min(360px, calc(100dvh - 180px)); grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; overflow: hidden; }
|
||||
.layout-preference-list { display: grid; max-height: min(520px, calc(100vh - 120px)); max-height: min(520px, calc(100dvh - 120px)); grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; overflow: hidden; }
|
||||
.layout-preference-list__hint { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.layout-preference-list__scroll { min-height: 0; padding-right: 4px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
|
||||
.layout-preference-list__scroll:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 1px; }
|
||||
|
||||
Reference in New Issue
Block a user