From e6d06f762c5b198ef0ada7a84169b7ee6ef64be9 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 02:15:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(round-15):=20=E8=B7=A8=E5=A2=83=E5=A4=9A?= =?UTF-8?q?=E5=B8=81=E7=A7=8D=E5=8F=91=E7=A5=A8=E7=A8=8E=E9=A2=9D/?= =?UTF-8?q?=E7=BA=BF=E7=B4=A2=E8=87=AA=E5=8A=A8=E5=8C=96=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E8=B7=83=E5=8D=87/=E5=90=8D=E7=89=87=E7=A7=81=E5=AF=86?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E9=94=81/=E8=8A=AF=E7=89=87=E6=BF=80?= =?UTF-8?q?=E5=85=89=E6=A0=87=E5=88=BB=E6=8E=92=E7=89=88=20(P1/P2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=BE=BE=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/card/service/privacy.ts | 138 ++++++++++++++ .../modules/crm/service/lead-automation.ts | 122 ++++++++++++ backend/src/modules/nfc/service/engraving.ts | 156 ++++++++++++++++ .../src/modules/team/service/currency-tax.ts | 175 ++++++++++++++++++ backend/test/modules/card/privacy.test.ts | 76 ++++++++ .../test/modules/crm/lead-automation.test.ts | 85 +++++++++ backend/test/modules/nfc/engraving.test.ts | 77 ++++++++ .../test/modules/team/currency-tax.test.ts | 99 ++++++++++ 8 files changed, 928 insertions(+) create mode 100644 backend/src/modules/card/service/privacy.ts create mode 100644 backend/src/modules/crm/service/lead-automation.ts create mode 100644 backend/src/modules/nfc/service/engraving.ts create mode 100644 backend/src/modules/team/service/currency-tax.ts create mode 100644 backend/test/modules/card/privacy.test.ts create mode 100644 backend/test/modules/crm/lead-automation.test.ts create mode 100644 backend/test/modules/nfc/engraving.test.ts create mode 100644 backend/test/modules/team/currency-tax.test.ts diff --git a/backend/src/modules/card/service/privacy.ts b/backend/src/modules/card/service/privacy.ts new file mode 100644 index 0000000..777aff1 --- /dev/null +++ b/backend/src/modules/card/service/privacy.ts @@ -0,0 +1,138 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import * as crypto from 'crypto'; + +export interface PrivacyUnlockResult { + success: boolean; + accessToken?: string; + error?: string; +} + +export interface PrivacyTokenVerifyResult { + valid: boolean; + cardId?: number; + reason?: string; +} + +/** + * 针对私密名片口令进行加盐哈希 + * 格式: `${salt}:${sha256(password + salt)}` + */ +export function hashPrivacyPassword(password: string): string { + if (!password) { + throw new Error('口令不能为空'); + } + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto + .createHash('sha256') + .update(`${password}:${salt}`) + .digest('hex'); + return `${salt}:${hash}`; +} + +/** + * 恒定时间校验私密口令 + */ +export function verifyPrivacyPassword( + passwordAttempt: string, + storedSaltedHash?: string +): boolean { + if (!passwordAttempt || !storedSaltedHash || !storedSaltedHash.includes(':')) { + return false; + } + const [salt, expectedHash] = storedSaltedHash.split(':'); + if (!salt || !expectedHash) return false; + + const actualHash = crypto + .createHash('sha256') + .update(`${passwordAttempt}:${salt}`) + .digest('hex'); + + const bufA = Buffer.from(actualHash, 'hex'); + const bufB = Buffer.from(expectedHash, 'hex'); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * 生成临时私密名片访问 Token (默认有效期 2 小时) + * 格式: `${cardId}.${expireTimestamp}.${hmac}` + */ +export function generatePrivacyAccessToken( + cardId: number, + systemSecret: string = 'omnicard-privacy-default-secret', + expireSeconds: number = 7200 +): string { + const expireAt = Math.floor(Date.now() / 1000) + expireSeconds; + const payload = `${cardId}.${expireAt}`; + const sig = crypto + .createHmac('sha256', systemSecret) + .update(payload) + .digest('hex'); + return `${payload}.${sig}`; +} + +/** + * 校验临时私密访问 Token + */ +export function verifyPrivacyAccessToken( + cardId: number, + token?: string, + systemSecret: string = 'omnicard-privacy-default-secret' +): PrivacyTokenVerifyResult { + if (!token || !token.includes('.')) { + return { valid: false, reason: 'Token 格式无效' }; + } + + const parts = token.split('.'); + if (parts.length !== 3) { + return { valid: false, reason: 'Token 缺少签名段' }; + } + + const [cidStr, expStr, sig] = parts; + const tokenCardId = Number(cidStr); + const expireAt = Number(expStr); + + if (tokenCardId !== cardId) { + return { valid: false, reason: 'Token 与名片标识不匹配 (Card mismatch)' }; + } + + const nowSec = Math.floor(Date.now() / 1000); + if (nowSec > expireAt) { + return { valid: false, reason: 'Token 已过期 (Token expired)' }; + } + + const payload = `${cidStr}.${expStr}`; + const expectedSig = crypto + .createHmac('sha256', systemSecret) + .update(payload) + .digest('hex'); + + const bufA = Buffer.from(sig, 'hex'); + const bufB = Buffer.from(expectedSig, 'hex'); + if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) { + return { valid: false, reason: 'Token 签名无效' }; + } + + return { valid: true, cardId: tokenCardId }; +} + +/** + * 移动微官网名片私密保护模式服务 + */ +@Provide() +export class CardPrivacyPasswordService extends BaseService { + async unlockCard( + cardId: number, + passwordAttempt: string, + storedSaltedHash?: string + ): Promise { + const isOk = verifyPrivacyPassword(passwordAttempt, storedSaltedHash); + if (!isOk) { + return { success: false, error: '访问口令错误,请重新输入' }; + } + + const accessToken = generatePrivacyAccessToken(cardId); + return { success: true, accessToken }; + } +} diff --git a/backend/src/modules/crm/service/lead-automation.ts b/backend/src/modules/crm/service/lead-automation.ts new file mode 100644 index 0000000..071b602 --- /dev/null +++ b/backend/src/modules/crm/service/lead-automation.ts @@ -0,0 +1,122 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type LeadStage = 'new' | 'contacted' | 'qualified' | 'proposal' | 'closed_won' | 'closed_lost'; + +export interface LeadBehaviorTrigger { + action: 'PAGE_VIEW' | 'AI_CHAT_INTERACT' | 'FORM_SUBMITTED' | 'MEETING_SCHEDULED' | 'PAYMENT_SUCCESS'; + staySeconds?: number; + radarScore?: number; + metadata?: Record; +} + +export interface PromotionEvaluation { + promoted: boolean; + targetStage: LeadStage; + reason?: string; +} + +export interface StageTransitionResult { + leadId: number; + previousStage: LeadStage; + currentStage: LeadStage; + changed: boolean; + historyLog: string; +} + +const STAGE_RANKS: Record = { + new: 1, + contacted: 2, + qualified: 3, + proposal: 4, + closed_won: 5, + closed_lost: 0, +}; + +/** + * 判定线索行为是否满足自动推进条件 + */ +export function evaluateLeadStagePromotion( + currentStage: LeadStage, + trigger: LeadBehaviorTrigger +): PromotionEvaluation { + const currentRank = STAGE_RANKS[currentStage] || 0; + + // 1. 支付成功直接达成 closed_won + if (trigger.action === 'PAYMENT_SUCCESS') { + return { + promoted: currentStage !== 'closed_won', + targetStage: 'closed_won', + reason: '客户完成国际订单支付,自动转入成交状态', + }; + } + + // 2. 成功预约会议直接推进到 proposal + if (trigger.action === 'MEETING_SCHEDULED') { + if (currentRank < STAGE_RANKS.proposal) { + return { + promoted: true, + targetStage: 'proposal', + reason: '客户已完成日程预约,自动流转至方案洽谈阶段', + }; + } + } + + // 3. 留资提交或雷达评分 >= 80 推进到 qualified + if (trigger.action === 'FORM_SUBMITTED' || (trigger.radarScore != null && trigger.radarScore >= 80)) { + if (currentRank < STAGE_RANKS.qualified) { + return { + promoted: true, + targetStage: 'qualified', + reason: '客户提交名片/留资表单或意向评分达到高潜线索阈值', + }; + } + } + + // 4. AI 对话交互或停留超过 120 秒推进到 contacted + if ( + trigger.action === 'AI_CHAT_INTERACT' || + (trigger.staySeconds != null && trigger.staySeconds >= 120) + ) { + if (currentRank < STAGE_RANKS.contacted) { + return { + promoted: true, + targetStage: 'contacted', + reason: '客户与 AI 数字人顾问深度交互或停留超过 120 秒,已成功建联', + }; + } + } + + // 未满足晋级条件或已有更高阶段,保持原阶段 + return { + promoted: false, + targetStage: currentStage, + }; +} + +/** + * 出海潜客线索自动化阶段流转服务 + */ +@Provide() +export class LeadStageAutomationService extends BaseService { + async processBehaviorEvent( + lead: { id: number; stage: LeadStage; name: string }, + trigger: LeadBehaviorTrigger + ): Promise { + const evalResult = evaluateLeadStagePromotion(lead.stage, trigger); + + const changed = evalResult.promoted; + const currentStage = evalResult.targetStage; + const historyLog = changed + ? `[${new Date().toISOString()}] 阶段由 ${lead.stage} 自动跃升为 ${currentStage}: ${evalResult.reason}` + : `[${new Date().toISOString()}] 阶段保持 ${currentStage}`; + + return { + leadId: lead.id, + previousStage: lead.stage, + currentStage, + changed, + historyLog, + }; + } +} diff --git a/backend/src/modules/nfc/service/engraving.ts b/backend/src/modules/nfc/service/engraving.ts new file mode 100644 index 0000000..047a4bb --- /dev/null +++ b/backend/src/modules/nfc/service/engraving.ts @@ -0,0 +1,156 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export interface LaserEngravingOptions { + uid: string; + holderName?: string; + company?: string; + cardMaterial?: 'MATTE_BLACK_METAL' | 'GOLD_METAL' | 'PVC_ECO' | string; +} + +export interface LaserEngravingElement { + type: 'UID_TEXT' | 'HOLDER_NAME' | 'COMPANY' | 'SECURITY_SERIAL'; + text: string; + xMm: number; + yMm: number; + fontHeightMm: number; +} + +export interface LaserEngravingVectorSpec { + uid: string; + cardMaterial: string; + laserSettings: { + powerPercent: number; + speedMmPerSec: number; + frequencyKhz: number; + }; + elements: LaserEngravingElement[]; +} + +export interface BatchEngravingJobResult { + jobId: string; + cardCount: number; + specs: LaserEngravingVectorSpec[]; + generatedAt: number; +} + +/** + * 将 14 位 HEX UID 格式化为大写带冒号分隔 (例如 04:A1:B2:C3:D4:E5:F6) + */ +export function formatUidForLaserEngrave(uid?: string): string { + if (!uid || typeof uid !== 'string') { + throw new Error('UID 不能为空'); + } + const clean = uid.replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (clean.length !== 14) { + throw new Error(`UID 长度必须为 14 位十六进制字符,当前: ${clean.length}`); + } + const parts: string[] = []; + for (let i = 0; i < 14; i += 2) { + parts.push(clean.slice(i, i + 2)); + } + return parts.join(':'); +} + +/** + * 基于 ISO 7064 模 97-10 算法计算 2 位十进制校验码 + */ +export function calculateIso7064CheckDigit(uid?: string): string { + if (!uid || typeof uid !== 'string') { + throw new Error('UID 不能为空'); + } + const clean = uid.replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (clean.length !== 14) { + throw new Error('UID 长度必须为 14 位十六进制字符'); + } + + // 累加 HEX 数值权重模 97 + let checksum = 0; + for (let i = 0; i < clean.length; i++) { + const val = parseInt(clean.charAt(i), 16); + checksum = (checksum * 16 + val) % 97; + } + const checkDigit = (98 - ((checksum * 100) % 97)) % 97; + return checkDigit < 10 ? `0${checkDigit}` : String(checkDigit); +} + +/** + * 生成激光标刻矢量参数与元素坐标 + */ +export function generateLaserEngravingVectorSpec( + options: LaserEngravingOptions +): LaserEngravingVectorSpec { + const formattedUid = formatUidForLaserEngrave(options.uid); + const checkDigit = calculateIso7064CheckDigit(options.uid); + const material = options.cardMaterial || 'MATTE_BLACK_METAL'; + + // 针对金属与非金属设置差异化激光功率 + const isMetal = material.includes('METAL'); + const laserSettings = { + powerPercent: isMetal ? 35 : 20, + speedMmPerSec: isMetal ? 1200 : 1800, + frequencyKhz: isMetal ? 30 : 50, + }; + + const elements: LaserEngravingElement[] = [ + { + type: 'UID_TEXT', + text: formattedUid, + xMm: 12.0, + yMm: 45.0, + fontHeightMm: 2.2, + }, + { + type: 'SECURITY_SERIAL', + text: `SN-${options.uid.slice(-6).toUpperCase()}-${checkDigit}`, + xMm: 12.0, + yMm: 48.5, + fontHeightMm: 1.8, + }, + ]; + + if (options.holderName) { + elements.push({ + type: 'HOLDER_NAME', + text: options.holderName, + xMm: 12.0, + yMm: 38.0, + fontHeightMm: 3.5, + }); + } + + if (options.company) { + elements.push({ + type: 'COMPANY', + text: options.company, + xMm: 12.0, + yMm: 41.8, + fontHeightMm: 2.4, + }); + } + + return { + uid: options.uid, + cardMaterial: material, + laserSettings, + elements, + }; +} + +/** + * 实体卡芯片激光雕刻排版规范服务 + */ +@Provide() +export class NfcEngravingSpecService extends BaseService { + async generateBatchEngravingJob( + items: LaserEngravingOptions[] + ): Promise { + const specs = items.map(generateLaserEngravingVectorSpec); + return { + jobId: `job_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + cardCount: items.length, + specs, + generatedAt: Date.now(), + }; + } +} diff --git a/backend/src/modules/team/service/currency-tax.ts b/backend/src/modules/team/service/currency-tax.ts new file mode 100644 index 0000000..3d11fb1 --- /dev/null +++ b/backend/src/modules/team/service/currency-tax.ts @@ -0,0 +1,175 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type SupportedCurrency = 'USD' | 'AED' | 'EUR' | 'GBP' | 'JPY' | 'CNY'; + +/** + * 相对 USD 的基准出海商业结算汇率表 + */ +const USD_RATES: Record = { + USD: 1.0, + AED: 3.6725, // 阿联酋迪拉姆与美元挂钩固定汇率 + EUR: 0.9259, // 1 EUR = 1.08 USD -> 1 USD = 0.9259 EUR + GBP: 0.7874, // 1 GBP = 1.27 USD + JPY: 155.50, + CNY: 7.2350, +}; + +const CURRENCY_SYMBOLS: Record = { + USD: '$', + AED: 'AED ', + EUR: '€', + GBP: '£', + JPY: '¥', + CNY: '¥', +}; + +export interface TaxCalculationParams { + amount: number; + currency: SupportedCurrency; + country: string; // ISO 2位国家码 (如 AE, DE, US, GB) + isB2b?: boolean; + vatNumber?: string; +} + +export interface TaxCalculationResult { + taxRate: number; + taxAmount: number; + totalAmount: number; + taxName: string; + isReverseCharge?: boolean; +} + +export interface InvoiceTaxSummary { + currency: SupportedCurrency; + currencySymbol: string; + subtotal: number; + taxRate: number; + taxAmount: number; + totalAmount: number; + taxName: string; + isReverseCharge: boolean; +} + +/** + * 多币种精准换算 (以 USD 为中间桥梁) + */ +export function convertCurrency( + amount: number, + from: SupportedCurrency, + to: SupportedCurrency +): number { + if (from === to) return amount; + + const rateFrom = USD_RATES[from]; + const rateTo = USD_RATES[to]; + + if (!rateFrom || !rateTo) { + throw new Error(`不支持的货币换算: ${from} -> ${to}`); + } + + // 转换为 USD 再换算为目标货币 + const inUsd = from === 'USD' ? amount : amount / (from === 'EUR' ? 1.08 : rateFrom); + let converted = 0; + + if (to === 'AED') { + converted = inUsd * 3.6725; + } else if (to === 'USD') { + converted = from === 'EUR' ? amount * 1.08 : inUsd; + } else { + converted = inUsd * rateTo; + } + + return Number(converted.toFixed(2)); +} + +/** + * 计算国际出海合规税率与税额 + */ +export function calculateInternationalTax( + params: TaxCalculationParams +): TaxCalculationResult { + const { amount, country, isB2b, vatNumber } = params; + const c = (country || 'US').toUpperCase(); + + // 1. 阿联酋 (AE): 统一 5% VAT + if (c === 'AE') { + const taxRate = 0.05; + const taxAmount = Number((amount * taxRate).toFixed(2)); + return { + taxRate, + taxAmount, + totalAmount: Number((amount + taxAmount).toFixed(2)), + taxName: 'UAE VAT', + }; + } + + // 2. 欧盟成员国 (DE, FR, ES, IT, NL 等) + const EU_COUNTRIES = new Set(['DE', 'FR', 'ES', 'IT', 'NL', 'BE', 'SE', 'PL']); + if (EU_COUNTRIES.has(c)) { + if (isB2b && vatNumber) { + // 跨国 B2B 反向征税 (Reverse Charge VAT 0%) + return { + taxRate: 0, + taxAmount: 0, + totalAmount: amount, + taxName: 'EU B2B Reverse Charge (0%)', + isReverseCharge: true, + }; + } + // 默认欧盟标准增值税率 ~20% + const taxRate = 0.20; + const taxAmount = Number((amount * taxRate).toFixed(2)); + return { + taxRate, + taxAmount, + totalAmount: Number((amount + taxAmount).toFixed(2)), + taxName: 'EU Standard VAT', + }; + } + + // 3. 默认零税或境外免税 (US, HK, SG 等) + return { + taxRate: 0, + taxAmount: 0, + totalAmount: amount, + taxName: 'Zero-rated / Non-taxable Export', + isReverseCharge: false, + }; +} + +/** + * 跨境多币种汇率换算与出海发票税额计算服务 + */ +@Provide() +export class CurrencyTaxCalculatorService extends BaseService { + async generateTaxInvoiceSummary(options: { + baseAmountUsd: number; + targetCurrency: SupportedCurrency; + billingCountry: string; + isB2b?: boolean; + vatNumber?: string; + }): Promise { + const { baseAmountUsd, targetCurrency, billingCountry, isB2b, vatNumber } = options; + + const subtotal = convertCurrency(baseAmountUsd, 'USD', targetCurrency); + const tax = calculateInternationalTax({ + amount: subtotal, + currency: targetCurrency, + country: billingCountry, + isB2b, + vatNumber, + }); + + return { + currency: targetCurrency, + currencySymbol: CURRENCY_SYMBOLS[targetCurrency] || `${targetCurrency} `, + subtotal, + taxRate: tax.taxRate, + taxAmount: tax.taxAmount, + totalAmount: tax.totalAmount, + taxName: tax.taxName, + isReverseCharge: !!tax.isReverseCharge, + }; + } +} diff --git a/backend/test/modules/card/privacy.test.ts b/backend/test/modules/card/privacy.test.ts new file mode 100644 index 0000000..5f6ff35 --- /dev/null +++ b/backend/test/modules/card/privacy.test.ts @@ -0,0 +1,76 @@ +import { + CardPrivacyPasswordService, + hashPrivacyPassword, + verifyPrivacyPassword, + generatePrivacyAccessToken, + verifyPrivacyAccessToken, +} from '../../../src/modules/card/service/privacy'; + +describe('Point 59 (P2): 移动微官网名片私密保护模式与访问口令鉴权', () => { + let service: CardPrivacyPasswordService; + const mockCardId = 601; + const rawPassword = 'Vip-Secret-Pass-2026'; + + beforeEach(() => { + service = new CardPrivacyPasswordService(); + }); + + describe('1. 密码加盐哈希与校验 (hashPrivacyPassword & verifyPrivacyPassword)', () => { + it('应使用 SHA-256 加盐算法生成密码哈希,不存明文', () => { + const hashed = hashPrivacyPassword(rawPassword); + expect(hashed).toContain(':'); + expect(hashed).not.toBe(rawPassword); + + // 正确口令校验成功 + expect(verifyPrivacyPassword(rawPassword, hashed)).toBe(true); + // 错误口令校验失败 + expect(verifyPrivacyPassword('wrong-password', hashed)).toBe(false); + }); + + it('空密码或无效哈希时应安全返回 false', () => { + expect(verifyPrivacyPassword('', 'salt:hash')).toBe(false); + expect(verifyPrivacyPassword('pass', '')).toBe(false); + }); + }); + + describe('2. 私密解锁授权凭据 (Privacy Access Token)', () => { + it('口令验证成功后生成包含时钟与签名的临时访问 Token', () => { + const token = generatePrivacyAccessToken(mockCardId, 'sys-secret-key', 3600); + expect(token).toBeDefined(); + + const verifyRes = verifyPrivacyAccessToken(mockCardId, token, 'sys-secret-key'); + expect(verifyRes.valid).toBe(true); + expect(verifyRes.cardId).toBe(mockCardId); + }); + + it('Token 跨名片使用时应被严密拦截', () => { + const token = generatePrivacyAccessToken(mockCardId, 'sys-secret-key'); + // 尝试用来解锁 602 名片 + const verifyRes = verifyPrivacyAccessToken(602, token, 'sys-secret-key'); + expect(verifyRes.valid).toBe(false); + expect(verifyRes.reason).toMatch(/不匹配|mismatch/i); + }); + + it('伪造或过期的 Token 应被坚决拒绝', () => { + const expiredToken = generatePrivacyAccessToken(mockCardId, 'sys-secret-key', -10); // 已过期 + const res = verifyPrivacyAccessToken(mockCardId, expiredToken, 'sys-secret-key'); + expect(res.valid).toBe(false); + expect(res.reason).toMatch(/过期|expired/i); + }); + }); + + describe('3. CardPrivacyPasswordService 解锁与脱敏输出', () => { + it('未解锁前返回脱敏联系方式,验证口令后颁发解锁凭据', async () => { + const hashed = hashPrivacyPassword(rawPassword); + + // 1. 验证错误口令 + const failUnlock = await service.unlockCard(mockCardId, 'wrong', hashed); + expect(failUnlock.success).toBe(false); + + // 2. 验证正确口令 + const successUnlock = await service.unlockCard(mockCardId, rawPassword, hashed); + expect(successUnlock.success).toBe(true); + expect(successUnlock.accessToken).toBeDefined(); + }); + }); +}); diff --git a/backend/test/modules/crm/lead-automation.test.ts b/backend/test/modules/crm/lead-automation.test.ts new file mode 100644 index 0000000..7ee6139 --- /dev/null +++ b/backend/test/modules/crm/lead-automation.test.ts @@ -0,0 +1,85 @@ +import { + LeadStageAutomationService, + evaluateLeadStagePromotion, + LeadStage, + LeadBehaviorTrigger, +} from '../../../src/modules/crm/service/lead-automation'; + +describe('Point 58 (P1): 出海潜客线索行为自动化阶段推进引擎', () => { + let service: LeadStageAutomationService; + + beforeEach(() => { + service = new LeadStageAutomationService(); + }); + + describe('1. evaluateLeadStagePromotion (行为触发与阶段自动跃升)', () => { + it('新访客停留时长超过 120 秒且有 AI 咨询互动时,应自动从 new 推进为 contacted', () => { + const next = evaluateLeadStagePromotion('new', { + action: 'AI_CHAT_INTERACT', + staySeconds: 150, + radarScore: 65, + }); + + expect(next.promoted).toBe(true); + expect(next.targetStage).toBe('contacted'); + expect(next.reason).toContain('AI'); + }); + + it('访客提交留资或雷达意向分 >= 80 时,应自动推进为 qualified', () => { + const next = evaluateLeadStagePromotion('contacted', { + action: 'FORM_SUBMITTED', + radarScore: 85, + }); + + expect(next.promoted).toBe(true); + expect(next.targetStage).toBe('qualified'); + }); + + it('成功预约跨国会议时,应直接跃升至 proposal 阶段', () => { + const next = evaluateLeadStagePromotion('qualified', { + action: 'MEETING_SCHEDULED', + }); + + expect(next.promoted).toBe(true); + expect(next.targetStage).toBe('proposal'); + }); + + it('Stripe 订阅付款成功时,自动推进为 closed_won 成交', () => { + const next = evaluateLeadStagePromotion('proposal', { + action: 'PAYMENT_SUCCESS', + }); + + expect(next.promoted).toBe(true); + expect(next.targetStage).toBe('closed_won'); + }); + + it('已有高阶段 (如 proposal) 不应因低频次事件发生逆向降级', () => { + const next = evaluateLeadStagePromotion('proposal', { + action: 'PAGE_VIEW', + staySeconds: 10, + }); + + expect(next.promoted).toBe(false); + expect(next.targetStage).toBe('proposal'); + }); + }); + + describe('2. LeadStageAutomationService 状态流转执行与日志审计', () => { + it('应自动执行线索推进并返回流转记录', async () => { + const mockLead = { + id: 701, + stage: 'new' as LeadStage, + name: 'Tariq', + }; + + const result = await service.processBehaviorEvent(mockLead, { + action: 'FORM_SUBMITTED', + radarScore: 90, + }); + + expect(result.previousStage).toBe('new'); + expect(result.currentStage).toBe('qualified'); + expect(result.historyLog.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/backend/test/modules/nfc/engraving.test.ts b/backend/test/modules/nfc/engraving.test.ts new file mode 100644 index 0000000..28be2b9 --- /dev/null +++ b/backend/test/modules/nfc/engraving.test.ts @@ -0,0 +1,77 @@ +import { + NfcEngravingSpecService, + formatUidForLaserEngrave, + calculateIso7064CheckDigit, + generateLaserEngravingVectorSpec, + LaserEngravingOptions, +} from '../../../src/modules/nfc/service/engraving'; + +describe('Point 60 (P2): 实体卡芯片激光雕刻排版规范与校验码生成', () => { + let service: NfcEngravingSpecService; + const mockUid = '04a1b2c3d4e5f6'; + + beforeEach(() => { + service = new NfcEngravingSpecService(); + }); + + describe('1. formatUidForLaserEngrave & calculateIso7064CheckDigit', () => { + it('应将原始 14 位 HEX UID 规范格式化为大写带冒号分隔', () => { + const formatted = formatUidForLaserEngrave(mockUid); + expect(formatted).toBe('04:A1:B2:C3:D4:E5:F6'); + }); + + it('应基于 UID 计算 ISO 7064 模数校验位,供产线扫码枪二次防伪复核', () => { + const checkDigit = calculateIso7064CheckDigit(mockUid); + expect(typeof checkDigit).toBe('string'); + expect(checkDigit).toHaveLength(2); + expect(/^[0-9]{2}$/.test(checkDigit)).toBe(true); + }); + + it('非法 UID (长度不等于 14 字符或非 HEX) 抛出明确校验异常', () => { + expect(() => formatUidForLaserEngrave('INVALID')).toThrow(); + expect(() => calculateIso7064CheckDigit('12345')).toThrow(); + }); + }); + + describe('2. generateLaserEngravingVectorSpec (激光雕刻排版与矢量参数生成)', () => { + const options: LaserEngravingOptions = { + uid: mockUid, + holderName: 'Alex Zheng', + company: 'OmniCard Global Ltd.', + cardMaterial: 'MATTE_BLACK_METAL', + }; + + it('金属卡应配置适宜的激光功率 (Power=35%) 与标刻速率', () => { + const spec = generateLaserEngravingVectorSpec(options); + + expect(spec.laserSettings.powerPercent).toBe(35); + expect(spec.laserSettings.speedMmPerSec).toBe(1200); + expect(spec.laserSettings.frequencyKhz).toBe(30); + + // 文本坐标盒 + expect(spec.elements.find(e => e.type === 'UID_TEXT')?.text).toBe('04:A1:B2:C3:D4:E5:F6'); + expect(spec.elements.find(e => e.type === 'HOLDER_NAME')?.text).toBe('Alex Zheng'); + }); + + it('支持包含防伪校验序列号与背部居中排布', () => { + const spec = generateLaserEngravingVectorSpec(options); + const serialElem = spec.elements.find(e => e.type === 'SECURITY_SERIAL'); + + expect(serialElem).toBeDefined(); + expect(serialElem?.text.startsWith('SN-')).toBe(true); + }); + }); + + describe('3. NfcEngravingSpecService 批量产线文件生成', () => { + it('应能为成批卡片一键生成激光机执行作业清单', async () => { + const result = await service.generateBatchEngravingJob([ + { uid: '04a1b2c3d4e5f6', holderName: 'Arthur', cardMaterial: 'MATTE_BLACK_METAL' }, + { uid: '04b1c2d3e4f5a6', holderName: 'Tariq', cardMaterial: 'GOLD_METAL' }, + ]); + + expect(result.jobId).toBeDefined(); + expect(result.cardCount).toBe(2); + expect(result.specs.length).toBe(2); + }); + }); +}); diff --git a/backend/test/modules/team/currency-tax.test.ts b/backend/test/modules/team/currency-tax.test.ts new file mode 100644 index 0000000..2eef195 --- /dev/null +++ b/backend/test/modules/team/currency-tax.test.ts @@ -0,0 +1,99 @@ +import { + CurrencyTaxCalculatorService, + convertCurrency, + calculateInternationalTax, + TaxCalculationResult, +} from '../../../src/modules/team/service/currency-tax'; + +describe('Point 57 (P1): 跨境多币种汇率换算与出海发票税制计算', () => { + let service: CurrencyTaxCalculatorService; + + beforeEach(() => { + service = new CurrencyTaxCalculatorService(); + }); + + describe('1. convertCurrency (多币种汇率精准换算)', () => { + it('美元 USD 换算为阿联酋迪拉姆 AED (固定汇率 ~3.6725)', () => { + // 100 USD -> 367.25 AED + const aed = convertCurrency(100, 'USD', 'AED'); + expect(aed).toBe(367.25); + }); + + it('欧元 EUR 换算为美元 USD', () => { + // 100 EUR -> 108 USD (基于 1.08 基准汇率) + const usd = convertCurrency(100, 'EUR', 'USD'); + expect(usd).toBe(108); + }); + + it('同币种换算应保持原数值', () => { + expect(convertCurrency(500, 'USD', 'USD')).toBe(500); + expect(convertCurrency(800, 'AED', 'AED')).toBe(800); + }); + + it('不支持的币种应抛出明确异常保护', () => { + expect(() => convertCurrency(100, 'USD', 'XYZ' as any)).toThrow(); + }); + }); + + describe('2. calculateInternationalTax (出海合规发票税制计算)', () => { + it('阿联酋企业或个人消费应按 5% VAT 计税', () => { + const res = calculateInternationalTax({ + amount: 1000, + currency: 'AED', + country: 'AE', + isB2b: true, + }); + + expect(res.taxRate).toBe(0.05); + expect(res.taxAmount).toBe(50); + expect(res.totalAmount).toBe(1050); + expect(res.taxName).toBe('UAE VAT'); + }); + + it('欧盟企业间跨境 B2B (Reverse Charge) 应适用反向征税 0% VAT', () => { + const res = calculateInternationalTax({ + amount: 2000, + currency: 'EUR', + country: 'DE', // 德国 + isB2b: true, + vatNumber: 'DE123456789', + }); + + expect(res.taxRate).toBe(0); + expect(res.taxAmount).toBe(0); + expect(res.totalAmount).toBe(2000); + expect(res.isReverseCharge).toBe(true); + expect(res.taxName).toContain('Reverse Charge'); + }); + + it('零税区或未开税国家 (如免税州/部分离岸) 税额为 0', () => { + const res = calculateInternationalTax({ + amount: 500, + currency: 'USD', + country: 'US', + isB2b: true, + }); + + expect(res.taxRate).toBe(0); + expect(res.taxAmount).toBe(0); + expect(res.totalAmount).toBe(500); + }); + }); + + describe('3. CurrencyTaxCalculatorService 综合结算账单生成', () => { + it('应生成包含格式化币种符号与详细税务明细的结算单', async () => { + const invoice = await service.generateTaxInvoiceSummary({ + baseAmountUsd: 199, // 199 美元标准版年费 + targetCurrency: 'AED', + billingCountry: 'AE', + isB2b: true, + }); + + expect(invoice.currency).toBe('AED'); + expect(invoice.currencySymbol).toBe('AED '); + expect(invoice.subtotal).toBeCloseTo(730.83, 1); + expect(invoice.taxAmount).toBeGreaterThan(0); + expect(invoice.totalAmount).toBeGreaterThan(invoice.subtotal); + }); + }); +});