This commit is contained in:
2026-08-07 14:47:11 +08:00
parent e7cc0cf18b
commit 7ac173fac4
21 changed files with 1714 additions and 791 deletions
@@ -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' })
}