chore: initial commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
getPasswordStrength,
|
||||
validateEditableAccountProfile,
|
||||
validatePasswordChange,
|
||||
} from './accountProfile.ts'
|
||||
|
||||
test('accepts a complete editable account profile', () => {
|
||||
assert.deepEqual(validateEditableAccountProfile({
|
||||
realName: '企业管理员',
|
||||
phone: '13800006601',
|
||||
email: 'admin@example.com',
|
||||
}), [])
|
||||
})
|
||||
|
||||
test('reports invalid profile fields together', () => {
|
||||
const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' })
|
||||
assert.equal(errors.length, 3)
|
||||
})
|
||||
|
||||
test('validates password length, equality and confirmation', () => {
|
||||
assert.deepEqual(validatePasswordChange({
|
||||
currentPassword: 'OldPass123',
|
||||
newPassword: 'NewPass456',
|
||||
confirmPassword: 'NewPass456',
|
||||
}), [])
|
||||
assert.equal(validatePasswordChange({ currentPassword: '', newPassword: '123', confirmPassword: '456' }).length, 3)
|
||||
assert.match(validatePasswordChange({ currentPassword: 'SamePass1', newPassword: 'SamePass1', confirmPassword: 'SamePass1' }).join('|'), /不能与当前密码相同/)
|
||||
assert.match(validatePasswordChange({ currentPassword: 'OldPass1', newPassword: 'abcdefgh', confirmPassword: 'abcdefgh' }).join('|'), /同时包含字母和数字/)
|
||||
})
|
||||
|
||||
test('grades password strength without making extra complexity mandatory', () => {
|
||||
assert.equal(getPasswordStrength('short'), 'weak')
|
||||
assert.equal(getPasswordStrength('Password1'), 'medium')
|
||||
assert.equal(getPasswordStrength('Stronger@2026'), 'strong')
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface EditableAccountProfile {
|
||||
realName: string
|
||||
phone: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface PasswordChangePayload {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] {
|
||||
const errors: string[] = []
|
||||
const realName = profile.realName.trim()
|
||||
const phone = profile.phone.trim()
|
||||
const email = profile.email.trim()
|
||||
|
||||
if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符')
|
||||
if (phone.length > 32) errors.push('联系电话不能超过 32 个字符')
|
||||
if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址')
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validatePasswordChange(payload: PasswordChangePayload): string[] {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!payload.currentPassword) errors.push('请输入当前密码')
|
||||
if (payload.newPassword.length < 8 || payload.newPassword.length > 64) {
|
||||
errors.push('新密码需为 8–64 个字符')
|
||||
}
|
||||
if (payload.newPassword.length >= 8 && (!/[a-zA-Z]/.test(payload.newPassword) || !/\d/.test(payload.newPassword))) {
|
||||
errors.push('新密码必须同时包含字母和数字')
|
||||
}
|
||||
if (payload.newPassword && payload.newPassword === payload.currentPassword) {
|
||||
errors.push('新密码不能与当前密码相同')
|
||||
}
|
||||
if (payload.confirmPassword !== payload.newPassword) errors.push('两次输入的新密码不一致')
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export type PasswordStrength = 'weak' | 'medium' | 'strong'
|
||||
|
||||
export function getPasswordStrength(password: string): PasswordStrength {
|
||||
let categoryCount = 0
|
||||
if (/[a-zA-Z]/.test(password)) categoryCount += 1
|
||||
if (/\d/.test(password)) categoryCount += 1
|
||||
if (/[^a-zA-Z\d]/.test(password)) categoryCount += 1
|
||||
|
||||
if (password.length >= 12 && categoryCount >= 3) return 'strong'
|
||||
if (password.length >= 8 && categoryCount >= 2) return 'medium'
|
||||
return 'weak'
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createFirstLegPartySnapshot, DEMO_FIRST_LEG_PARTIES } from '../data/firstLegParties.ts'
|
||||
import {
|
||||
canClientCancelFirstLegOrder,
|
||||
canClientEditFirstLegOrder,
|
||||
createEmptyCargoItem,
|
||||
getFirstLegStatusLabel,
|
||||
getFirstLegTransportModeLabel,
|
||||
summarizeFirstLegCargo,
|
||||
validateFirstLegOrderDraft,
|
||||
} from './firstLegOrder.ts'
|
||||
|
||||
function validDraft() {
|
||||
return {
|
||||
referenceNo: 'REF-001',
|
||||
transportMode: 'FCL',
|
||||
businessType: 'EXPORT',
|
||||
origin: '宁波',
|
||||
destination: '洛杉矶',
|
||||
receivingWarehouseId: 'WH005',
|
||||
expectedDepartureDate: '2026-08-08',
|
||||
shipperPartyId: 'CP001',
|
||||
shipperPartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[0], 'SHIPPER'),
|
||||
shipper: '示例贸易有限公司',
|
||||
consigneePartyId: 'CP003',
|
||||
consigneePartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[2], 'CONSIGNEE'),
|
||||
consignee: 'DEMO GLOBAL LLC',
|
||||
priority: 'NORMAL',
|
||||
customerRemark: '',
|
||||
tradeTerm: 'FOB',
|
||||
destinationCountryCode: 'US',
|
||||
destinationWarehouseName: 'ONT8',
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
channelCode: 'SEA-US-FBA',
|
||||
channelName: '美国海运渠道',
|
||||
taxMode: 'BUY_DECLARATION',
|
||||
customsInspection: false,
|
||||
destinationType: 'FBA',
|
||||
amazonWarehouseCode: 'ONT8',
|
||||
shipmentId: 'FBA-001',
|
||||
items: [{ ...createEmptyCargoItem('cargo-1'), cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }],
|
||||
}
|
||||
}
|
||||
|
||||
test('uses the confirmed status and transport labels', () => {
|
||||
assert.equal(getFirstLegStatusLabel('BOOKED'), '已订舱')
|
||||
assert.equal(getFirstLegStatusLabel('IN_TRANSIT'), '运输中')
|
||||
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
|
||||
})
|
||||
|
||||
test('only allows draft orders to be edited in the confirmed phase-one flow', () => {
|
||||
assert.equal(canClientEditFirstLegOrder('DRAFT'), true)
|
||||
assert.equal(canClientEditFirstLegOrder('PENDING_REVIEW'), false)
|
||||
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
|
||||
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
|
||||
assert.equal(canClientCancelFirstLegOrder('ACCEPTED'), false)
|
||||
assert.equal(canClientCancelFirstLegOrder('BOOKED'), false)
|
||||
})
|
||||
|
||||
test('validates the confirmed minimum submission fields', () => {
|
||||
assert.deepEqual(validateFirstLegOrderDraft(validDraft()), [])
|
||||
|
||||
const invalid = validDraft()
|
||||
invalid.origin = ''
|
||||
invalid.receivingWarehouseId = ''
|
||||
invalid.items[0].grossWeightKg = 0
|
||||
invalid.items[0].containerType = ''
|
||||
invalid.items[0].containerCount = 0
|
||||
|
||||
const errors = validateFirstLegOrderDraft(invalid)
|
||||
assert.ok(errors.includes('请填写起运地'))
|
||||
assert.ok(errors.includes('请选择平台收货仓'))
|
||||
assert.ok(errors.includes('第 1 条货物毛重必须大于 0'))
|
||||
assert.ok(errors.includes('第 1 条货物需要填写箱型和柜量'))
|
||||
})
|
||||
|
||||
test('requires enabled-party snapshots and order addresses before submission', () => {
|
||||
const draft = validDraft()
|
||||
draft.shipperPartyId = ''
|
||||
draft.shipperPartySnapshot = null
|
||||
draft.consigneePartySnapshot.address = ''
|
||||
|
||||
const errors = validateFirstLegOrderDraft(draft)
|
||||
assert.ok(errors.includes('请选择发货单位'))
|
||||
assert.ok(errors.includes('请填写本单收货地址'))
|
||||
})
|
||||
|
||||
test('does not require container details for non-FCL cargo', () => {
|
||||
const draft = validDraft()
|
||||
draft.transportMode = 'AIR'
|
||||
draft.items[0].containerType = ''
|
||||
draft.items[0].containerCount = 0
|
||||
|
||||
assert.deepEqual(validateFirstLegOrderDraft(draft), [])
|
||||
})
|
||||
|
||||
test('summarizes cargo values from line items', () => {
|
||||
const first = validDraft().items[0]
|
||||
const second = { ...first, id: 'cargo-2', pieces: 40, packageCount: 4, grossWeightKg: 300, volumeCbm: 1.25 }
|
||||
|
||||
assert.deepEqual(summarizeFirstLegCargo([first, second]), {
|
||||
pieces: 140,
|
||||
packageCount: 5,
|
||||
grossWeightKg: 1500,
|
||||
volumeCbm: 4.75,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
FIRST_LEG_ORDER_STATUSES,
|
||||
FIRST_LEG_TRANSPORT_MODES,
|
||||
type FirstLegOrderStatus,
|
||||
type FirstLegTransportMode,
|
||||
} from '@chongchong/shared'
|
||||
import type { FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts'
|
||||
|
||||
const statusLabels = new Map<FirstLegOrderStatus, string>(
|
||||
FIRST_LEG_ORDER_STATUSES.map((item) => [item.code, item.label]),
|
||||
)
|
||||
|
||||
const transportModeLabels = new Map<FirstLegTransportMode, string>(
|
||||
FIRST_LEG_TRANSPORT_MODES.map((item) => [item.code, item.label]),
|
||||
)
|
||||
|
||||
export function getFirstLegStatusLabel(status: FirstLegOrderStatus) {
|
||||
return statusLabels.get(status) ?? status
|
||||
}
|
||||
|
||||
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode) {
|
||||
return transportModeLabels.get(mode) ?? mode
|
||||
}
|
||||
|
||||
export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'danger' {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'DRAFT' || status === 'PENDING_REVIEW') return 'warning'
|
||||
if (status === 'CANCELLED') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function canClientEditFirstLegOrder(status: FirstLegOrderStatus) {
|
||||
return status === 'DRAFT'
|
||||
}
|
||||
|
||||
export function canClientCancelFirstLegOrder(status: FirstLegOrderStatus) {
|
||||
return status === 'DRAFT' || status === 'PENDING_REVIEW'
|
||||
}
|
||||
|
||||
export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoItem {
|
||||
return {
|
||||
id,
|
||||
sku: '',
|
||||
cargoNameCn: '',
|
||||
cargoNameEn: '',
|
||||
pieces: 1,
|
||||
packageCount: 1,
|
||||
grossWeightKg: 0,
|
||||
volumeCbm: 0,
|
||||
containerType: '',
|
||||
containerCount: 0,
|
||||
remark: '',
|
||||
packageType: 'CARTON',
|
||||
hsCode: '',
|
||||
declaredValue: 0,
|
||||
currency: 'USD',
|
||||
dangerousGoods: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!draft.origin.trim()) errors.push('请填写起运地')
|
||||
if (!draft.destination.trim()) errors.push('请填写目的地')
|
||||
if (!draft.destinationCountryCode.trim()) errors.push('请填写目的国两位代码')
|
||||
if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称')
|
||||
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
|
||||
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
|
||||
if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位')
|
||||
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址')
|
||||
if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位')
|
||||
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址')
|
||||
if (draft.items.length === 0) errors.push('请至少添加一条货物明细')
|
||||
|
||||
draft.items.forEach((item, index) => {
|
||||
const line = `第 ${index + 1} 条货物`
|
||||
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
|
||||
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
|
||||
if (!Number.isInteger(item.pieces) || item.pieces <= 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)) {
|
||||
errors.push(`${line}需要填写箱型和柜量`)
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function summarizeFirstLegCargo(items: FirstLegCargoItem[]) {
|
||||
return items.reduce(
|
||||
(summary, item) => ({
|
||||
pieces: summary.pieces + item.pieces,
|
||||
packageCount: summary.packageCount + item.packageCount,
|
||||
grossWeightKg: summary.grossWeightKg + item.grossWeightKg,
|
||||
volumeCbm: summary.volumeCbm + item.volumeCbm,
|
||||
}),
|
||||
{ pieces: 0, packageCount: 0, grossWeightKg: 0, volumeCbm: 0 },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { decodeMerchantToken } from './jwt.ts'
|
||||
|
||||
function tokenFor(payload) {
|
||||
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `header.${encoded}.signature`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user_id: 1001,
|
||||
username: 'merchant_admin',
|
||||
tenant_id: 2001,
|
||||
membership_id: 3001,
|
||||
app_code: 'MERCHANT',
|
||||
roles: ['MERCHANT_OWNER'],
|
||||
permissions: ['merchant:profile:view'],
|
||||
exp: 1900000000,
|
||||
}
|
||||
|
||||
test('decodes a merchant JWT payload for UI identity and permissions', () => {
|
||||
assert.deepEqual(decodeMerchantToken(tokenFor(payload)), payload)
|
||||
})
|
||||
|
||||
test('rejects tokens from the platform application domain', () => {
|
||||
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, app_code: 'PLATFORM' })), /账号不属于当前应用端/)
|
||||
})
|
||||
|
||||
test('rejects malformed tokens', () => {
|
||||
assert.throws(() => decodeMerchantToken('not-a-jwt'), /登录凭证格式无效/)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface MerchantJwtPayload {
|
||||
user_id: number | string
|
||||
username: string
|
||||
tenant_id: number | string
|
||||
operator_tenant_id?: number | string
|
||||
membership_id: number | string
|
||||
app_code: string
|
||||
client_type?: string
|
||||
roles?: string[]
|
||||
permissions?: string[]
|
||||
exp: number
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
export function decodeMerchantToken(token: string): MerchantJwtPayload {
|
||||
const payloadPart = token.split('.')[1]
|
||||
if (!payloadPart) throw new Error('登录凭证格式无效')
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(decodeBase64Url(payloadPart))
|
||||
if (!value || typeof value !== 'object') throw new Error()
|
||||
const payload = value as Partial<MerchantJwtPayload>
|
||||
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
|
||||
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
|
||||
return payload as MerchantJwtPayload
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
|
||||
throw new Error('登录凭证无法解析,请重新登录')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
|
||||
|
||||
test('accepts the backend-confirmed JPG and PNG formats up to 5MB', () => {
|
||||
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
|
||||
})
|
||||
|
||||
test('rejects PDF, empty, mismatched, and oversized files', () => {
|
||||
assert.match(validateQualificationFile({ name: '营业执照.pdf', size: 1024, type: 'application/pdf' }), /格式不支持/)
|
||||
assert.match(validateQualificationFile({ name: '伪装图片.png', size: 1024, type: 'text/plain' }), /内容类型/)
|
||||
assert.match(validateQualificationFile({ name: '空文件.png', size: 0, type: 'image/png' }), /内容为空/)
|
||||
assert.match(validateQualificationFile({ name: '超大文件.jpg', size: QUALIFICATION_MAX_FILE_SIZE + 1, type: 'image/jpeg' }), /超过 5MB/)
|
||||
})
|
||||
|
||||
test('formats selected file sizes for review', () => {
|
||||
assert.equal(formatQualificationFileSize(512), '512 B')
|
||||
assert.equal(formatQualificationFileSize(1536), '1.5 KB')
|
||||
assert.equal(formatQualificationFileSize(2 * 1024 * 1024), '2.0 MB')
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png'])
|
||||
|
||||
export interface QualificationFileLike {
|
||||
name: string
|
||||
size: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export function validateQualificationFile(file: QualificationFileLike) {
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
|
||||
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 JPG 或 PNG 图片'
|
||||
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
|
||||
if (file.size <= 0) return '文件内容为空,请重新选择'
|
||||
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatQualificationFileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
Reference in New Issue
Block a user