diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 665193f..e3486ec 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -1,4 +1,4 @@ -import { apiRequest } from '@/api/http' +import { apiBlobRequest, apiRequest } from '@/api/http' export type ApiFirstLegStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' | 'PROCESSING' | 'COMPLETED' export type ApiTransportMode = 'SEA' | 'AIR' | 'RAIL' | 'TRUCK' @@ -95,8 +95,28 @@ export interface FirstLegOrderCargoPayload { skuNo?: string upcNo?: string cargoNameEn?: string + customsHsCode?: string + unitsPerPackage?: number totalQuantity?: number netWeightKg?: number + unitDeclaredValue?: number + material?: string + usage?: string + model?: string + brand?: string + productLengthCm?: number + productWidthCm?: number + productHeightCm?: number + shipmentId?: string + referenceId?: string + productImageFileId?: number | string +} + +export interface ProductImageUploadResult { + fileId: number | string + fileName: string + contentType: string + sizeBytes: number } export interface FirstLegOrderSaveRequest { @@ -196,6 +216,16 @@ export interface MerchantFirstLegOrderDetail { statusHistory: MerchantOrderStatusHistory[] } +export function uploadMerchantProductImage(file: File) { + const data = new FormData() + data.append('file', file) + return apiRequest({ method: 'POST', url: '/merchant/v1/files/product-images', data }) +} + +export function downloadMerchantProductImage(fileId: number | string) { + return apiBlobRequest({ method: 'GET', url: `/merchant/v1/files/product-images/${fileId}` }) +} + export interface FirstLegOrderQuery { keyword?: string status?: ApiFirstLegStatus diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 07e1891..1cdb121 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -88,8 +88,6 @@ test('sends backend-required channel, pricing, customs, destination and cargo fi assert.match(storeSource, /destinationWarehouseId: draft\.destinationType === 'PRIVATE' \? undefined : draft\.destinationWarehouseId/) assert.match(storeSource, /draft\.pickupMethod === 'PICKUP'.*draft\.shipperPartySnapshot/s) assert.match(storeSource, /draft\.destinationType === 'PRIVATE'.*draft\.consigneePartySnapshot/s) - for (const dimension of ['lengthCm', 'widthCm', 'heightCm']) assert.match(storeSource, new RegExp(`${dimension}: item\\.${dimension}`)) - assert.match(storeSource, /inferCargoBoxSizeType\(item\.lengthCm, item\.widthCm, item\.heightCm\)/) }) test('separates domestic receiving warehouses from public destinations', () => { diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 4cd0cee..fe5dd98 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -27,7 +27,7 @@ import type { MerchantTenantStatus } from '@/modules/tenant-management/public' import type { PlatformReceivingWarehouse, PlatformReceivingWarehouseSnapshot } from '@chongchong/shared' import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties' import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder' -import { canClientEditFirstLegOrder, getFirstLegStatusLabel, inferCargoBoxSizeType } from '@/modules/first-leg/utils/firstLegOrder' +import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder' import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination' export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS' @@ -107,6 +107,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust cargoName: item.cargoNameCn, cargoNameEn: item.cargoNameEn || undefined, hsCode: item.hsCode || undefined, + customsHsCode: item.customsHsCode || undefined, packageType: item.packageType, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg, @@ -121,6 +122,19 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust skuNo: item.sku || undefined, upcNo: item.upc || undefined, totalQuantity: item.pieces, + unitsPerPackage: item.unitsPerPackage || undefined, + netWeightKg: item.netWeightKg || undefined, + unitDeclaredValue: item.unitDeclaredValue || undefined, + material: item.material || undefined, + usage: item.usage || undefined, + model: item.model || undefined, + brand: item.brand || undefined, + productLengthCm: item.productLengthCm || undefined, + productWidthCm: item.productWidthCm || undefined, + productHeightCm: item.productHeightCm || undefined, + shipmentId: item.shipmentId || undefined, + referenceId: item.referenceId || undefined, + productImageFileId: item.productImageFileId || undefined, })), pickupMethod: draft.pickupMethod, receivingWarehouseId: warehouse.id, @@ -133,8 +147,8 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust customsInspection: draft.customsInspection, destinationType: draft.destinationType, amazonWarehouseCode: draft.destinationType === 'FBA' ? draft.amazonWarehouseCode || undefined : undefined, - shipmentId: draft.destinationType === 'FBA' ? draft.shipmentId || undefined : undefined, - referenceId: draft.referenceNo || undefined, + shipmentId: draft.destinationType === 'FBA' ? draft.items.find((item) => item.shipmentId)?.shipmentId || undefined : undefined, + referenceId: draft.items.find((item) => item.referenceId)?.referenceId || draft.referenceNo || undefined, } } @@ -275,13 +289,19 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar status: clientStatus(order.status), version: order.version ?? 0, items: (detail?.cargoes ?? []).map((item) => ({ id: String(item.id), sku: item.skuNo ?? '', upc: item.upcNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '', + productImageFileId: item.productImageFileId == null ? '' : String(item.productImageFileId), + productImageName: '', productImagePreviewUrl: '', productImageUploading: false, productImageError: '', + customsHsCode: item.customsHsCode ?? '', lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0, + heightCm: item.heightCm ?? 0, unitsPerPackage: item.unitsPerPackage ?? 1, pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg, - volumeCbm: item.volumeCbm, - boxSizeType: inferCargoBoxSizeType(item.lengthCm, item.widthCm, item.heightCm), - lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0, heightCm: item.heightCm ?? 0, - containerType: '', containerCount: 0, remark: item.cargoRemark ?? '', + volumeCbm: item.volumeCbm, containerType: '', containerCount: 0, remark: item.cargoRemark ?? '', packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0, currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false, + netWeightKg: item.netWeightKg ?? 0, unitDeclaredValue: item.unitDeclaredValue ?? 0, + material: item.material ?? '', usage: item.usage ?? '', model: item.model ?? '', brand: item.brand ?? '', + productLengthCm: item.productLengthCm ?? 0, productWidthCm: item.productWidthCm ?? 0, + productHeightCm: item.productHeightCm ?? 0, shipmentId: item.shipmentId ?? order.shipmentId ?? '', + referenceId: item.referenceId ?? order.referenceId ?? '', })), timeline: detail ? timeline(detail) : [], businessSummary: businessSummary(), createdAt: order.createdAt, updatedAt: order.updatedAt, tradeTerm: order.tradeTerm ?? '', destinationCountryCode: order.destinationCountryCode, diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index ad41634..85f3b13 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -7,7 +7,6 @@ import type { FirstLegPartySnapshot } from '../data/firstLegParties.ts' export type FirstLegBusinessType = 'EXPORT' | 'IMPORT' export type FirstLegPriority = 'NORMAL' | 'URGENT' | 'EXPEDITED' -export type CargoBoxSizeType = '' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE' export interface FirstLegCargoItem { id: string @@ -15,14 +14,31 @@ export interface FirstLegCargoItem { upc: string cargoNameCn: string cargoNameEn: string + productImageFileId: string + productImageName: string + productImagePreviewUrl: string + productImageUploading: boolean + productImageError: string + customsHsCode: string + lengthCm: number + widthCm: number + heightCm: number + unitsPerPackage: number pieces: number packageCount: number grossWeightKg: number volumeCbm: number - boxSizeType: CargoBoxSizeType - lengthCm: number - widthCm: number - heightCm: number + netWeightKg: number + unitDeclaredValue: number + material: string + usage: string + model: string + brand: string + productLengthCm: number + productWidthCm: number + productHeightCm: number + shipmentId: string + referenceId: string containerType: string containerCount: number remark: string diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index 089c7ba..8187b03 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -2,14 +2,12 @@ import assert from 'node:assert/strict' import test from 'node:test' import { createFirstLegPartySnapshot } from '../data/firstLegParties.ts' import { - applyCargoBoxSize, canClientCancelFirstLegOrder, canClientEditFirstLegOrder, createEmptyCargoItem, getFirstLegStatusLabel, getFirstLegTransportModeLabel, summarizeFirstLegCargo, - updateCargoBoxVolume, validateFirstLegOrderDraft, } from './firstLegOrder.ts' @@ -54,24 +52,10 @@ function validDraft() { destinationType: 'FBA', amazonWarehouseCode: 'ONT8', shipmentId: 'FBA-001', - items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, boxSizeType: 'SMALL', lengthCm: 34, widthCm: 24, heightCm: 24, containerType: '40HQ', containerCount: 1 }], + items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }], } } -test('applies the four fixed box sizes and recalculates total CBM from package count', () => { - const item = createEmptyCargoItem('cargo-box') - item.packageCount = 2 - applyCargoBoxSize(item, 'SMALL') - assert.deepEqual([item.lengthCm, item.widthCm, item.heightCm, item.volumeCbm], [34, 24, 24, 0.039]) - - item.packageCount = 3 - updateCargoBoxVolume(item) - assert.equal(item.volumeCbm, 0.059) - - applyCargoBoxSize(item, '') - assert.deepEqual([item.lengthCm, item.widthCm, item.heightCm, item.volumeCbm], [0, 0, 0, 0]) -}) - test('uses the confirmed status and transport labels', () => { assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据') assert.equal(getFirstLegStatusLabel('INBOUNDED'), '已入库') diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index d0db790..0bf283f 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -4,46 +4,7 @@ import { type FirstLegOrderStatus, type FirstLegTransportMode, } from '@chongchong/shared' -import type { CargoBoxSizeType, FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts' - -export const CARGO_BOX_SIZES = [ - { code: 'SMALL', label: '小箱', inner: '30 × 20 × 20', lengthCm: 34, widthCm: 24, heightCm: 24 }, - { code: 'MEDIUM', label: '中箱', inner: '40 × 30 × 30', lengthCm: 44, widthCm: 34, heightCm: 34 }, - { code: 'LARGE', label: '大箱', inner: '50 × 40 × 40', lengthCm: 54, widthCm: 44, heightCm: 44 }, - { code: 'EXTRA_LARGE', label: '特大箱', inner: '60 × 50 × 50', lengthCm: 64, widthCm: 54, heightCm: 54 }, -] as const satisfies ReadonlyArray<{ - code: Exclude - label: string - inner: string - lengthCm: number - widthCm: number - heightCm: number -}> - -export function inferCargoBoxSizeType(lengthCm?: number, widthCm?: number, heightCm?: number): CargoBoxSizeType { - return CARGO_BOX_SIZES.find((box) => box.lengthCm === lengthCm && box.widthCm === widthCm && box.heightCm === heightCm)?.code ?? '' -} - -export function applyCargoBoxSize(item: FirstLegCargoItem, type: CargoBoxSizeType) { - item.boxSizeType = type - const box = CARGO_BOX_SIZES.find((option) => option.code === type) - if (!box) { - item.lengthCm = 0 - item.widthCm = 0 - item.heightCm = 0 - item.volumeCbm = 0 - return - } - item.lengthCm = box.lengthCm - item.widthCm = box.widthCm - item.heightCm = box.heightCm - updateCargoBoxVolume(item) -} - -export function updateCargoBoxVolume(item: FirstLegCargoItem) { - if (!item.boxSizeType || item.packageCount <= 0) return - item.volumeCbm = Number(((item.lengthCm * item.widthCm * item.heightCm * item.packageCount) / 1_000_000).toFixed(3)) -} +import type { FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts' const statusLabels = new Map( FIRST_LEG_ORDER_STATUSES.map((item) => [item.code, item.label]), @@ -92,14 +53,31 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI upc: '', cargoNameCn: '', cargoNameEn: '', + productImageFileId: '', + productImageName: '', + productImagePreviewUrl: '', + productImageUploading: false, + productImageError: '', + customsHsCode: '', + lengthCm: 0, + widthCm: 0, + heightCm: 0, + unitsPerPackage: 1, pieces: 1, packageCount: 1, grossWeightKg: 0, volumeCbm: 0, - boxSizeType: '', - lengthCm: 0, - widthCm: 0, - heightCm: 0, + netWeightKg: 0, + unitDeclaredValue: 0, + material: '', + usage: '', + model: '', + brand: '', + productLengthCm: 0, + productWidthCm: 0, + productHeightCm: 0, + shipmentId: '', + referenceId: '', containerType: '', containerCount: 0, remark: '', @@ -138,19 +116,20 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) { if (draft.items.length === 0) errors.push('请至少添加一条货物明细') if (draft.destinationType === 'FBA') { - if (!draft.referenceNo.trim()) errors.push('FBA 订单请填写客户参考号') if (!draft.amazonWarehouseCode.trim()) errors.push('FBA 订单请填写 Amazon 仓库编码') - if (!draft.shipmentId.trim()) errors.push('FBA 订单请填写 Shipment ID') } draft.items.forEach((item, index) => { const line = `第 ${index + 1} 条货物` if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`) if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`) - if (!item.boxSizeType) errors.push(`${line}请选择箱规`) if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`) if (draft.destinationType === 'FBA' && !item.upc.trim()) errors.push(`${line}缺少 UPC`) + if (draft.destinationType === 'FBA' && !item.shipmentId.trim()) errors.push(`${line}缺少 Shipment ID`) + if (draft.destinationType === 'FBA' && !item.referenceId.trim()) errors.push(`${line}缺少 Reference ID`) if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`) + if (!Number.isInteger(item.packageCount) || item.packageCount <= 0) errors.push(`${line}箱数必须为正整数`) + if (!Number.isInteger(item.unitsPerPackage) || item.unitsPerPackage <= 0) errors.push(`${line}每箱数量必须为正整数`) if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`) if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`) if (draft.transportMode === 'FCL' && (!item.containerType || item.containerCount <= 0)) { diff --git a/src/modules/first-leg/utils/productImage.test.mjs b/src/modules/first-leg/utils/productImage.test.mjs new file mode 100644 index 0000000..f2fbcc1 --- /dev/null +++ b/src/modules/first-leg/utils/productImage.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { PRODUCT_IMAGE_MAX_SIZE, validateProductImageFile } from './productImage.ts' + +test('accepts JPG and PNG product images up to 5MB', () => { + assert.equal(validateProductImageFile({ name: 'product.JPG', size: PRODUCT_IMAGE_MAX_SIZE, type: 'image/jpeg' }), '') + assert.equal(validateProductImageFile({ name: 'product.png', size: 1024, type: 'image/png' }), '') +}) + +test('rejects empty, oversized, mismatched, and unsupported product image files', () => { + assert.match(validateProductImageFile({ name: 'product.png', size: 0, type: 'image/png' }), /内容为空/) + assert.match(validateProductImageFile({ name: 'product.png', size: PRODUCT_IMAGE_MAX_SIZE + 1, type: 'image/png' }), /超过 5MB/) + assert.match(validateProductImageFile({ name: 'product.jpg', size: 1024, type: 'image/png' }), /仅支持/) + assert.match(validateProductImageFile({ name: 'product.webp', size: 1024, type: 'image/webp' }), /仅支持/) +}) diff --git a/src/modules/first-leg/utils/productImage.ts b/src/modules/first-leg/utils/productImage.ts new file mode 100644 index 0000000..1600dba --- /dev/null +++ b/src/modules/first-leg/utils/productImage.ts @@ -0,0 +1,14 @@ +const allowedTypes = new Set(['image/jpeg', 'image/png']) +const allowedExtensions = new Set(['jpg', 'jpeg', 'png']) + +export const PRODUCT_IMAGE_MAX_SIZE = 5 * 1024 * 1024 + +export function validateProductImageFile(file: Pick) { + const extension = file.name.split('.').pop()?.toLowerCase() ?? '' + if (!allowedTypes.has(file.type.toLowerCase()) || !allowedExtensions.has(extension)) { + return '产品图片仅支持 JPG、JPEG、PNG 格式' + } + if (file.size <= 0) return '产品图片内容为空' + if (file.size > PRODUCT_IMAGE_MAX_SIZE) return '产品图片不能超过 5MB' + return '' +} diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 1e0bb75..eb58bc8 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -1,8 +1,8 @@