feat(round-15): 跨境多币种发票税额/线索自动化阶段跃升/名片私密访问锁/芯片激光标刻排版 (P1/P2功能达成)
这个提交包含在:
@@ -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<PrivacyUnlockResult> {
|
||||
const isOk = verifyPrivacyPassword(passwordAttempt, storedSaltedHash);
|
||||
if (!isOk) {
|
||||
return { success: false, error: '访问口令错误,请重新输入' };
|
||||
}
|
||||
|
||||
const accessToken = generatePrivacyAccessToken(cardId);
|
||||
return { success: true, accessToken };
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<LeadStage, number> = {
|
||||
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<StageTransitionResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<BatchEngravingJobResult> {
|
||||
const specs = items.map(generateLaserEngravingVectorSpec);
|
||||
return {
|
||||
jobId: `job_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
cardCount: items.length,
|
||||
specs,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<SupportedCurrency, number> = {
|
||||
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<SupportedCurrency, string> = {
|
||||
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<InvoiceTaxSummary> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户