Substitute org name and balance tokens in outbound SMS so templates no longer leave {از طرف} literal.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-14 17:12:31 +03:30
co-authored by Cursor
parent f36fddc23b
commit 717a41e94b
12 changed files with 666 additions and 200 deletions
+68 -1
View File
@@ -21,15 +21,82 @@ function tehranJalaliLong(d: Date) {
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
}
/** قالب بس/بد مثل UI ته‌حساب — برای توکن‌های مانده در پیامک */
export function formatSmsBedBes(v: unknown, maximumFractionDigits = 3): string {
if (v == null) return '';
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '';
const n = Number(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
const abs = toFaDigits(
new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(Math.abs(n)),
);
if (n === 0) return abs;
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
}
const METAL_FIELD_LABELS: Record<string, string> = {
MandeyeVazni: 'طلا',
MandeyeVazniNoghre: 'نقره',
MandeyeKolBeTALA: 'کل به طلا',
MandeMenhayeSeke: 'منهای سکه',
};
function isMetalMandehKey(key: string) {
if (METAL_FIELD_LABELS[key]) return true;
if (key.startsWith('Seke_')) return true;
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
}
function metalFieldLabel(key: string) {
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
return key;
}
/** از فیلدهای مانده ته‌حساب، متن مانده ریالی و فلزی می‌سازد */
export function smsBalanceTexts(input: {
mandehFields?: Array<{ key: string; value: string | null }> | null;
rialBalance?: number | null;
goldBalance?: number | null;
}): { rialBalance: string; metalBalance: string } {
const fields = input.mandehFields ?? [];
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
const rial =
formatSmsBedBes(mali?.value ?? input.rialBalance) ||
formatSmsBedBes(input.rialBalance);
const metalParts = fields
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
.map((f) => `${metalFieldLabel(f.key)} ${formatSmsBedBes(f.value)}`.trim())
.filter(Boolean);
const metal =
metalParts.join('، ') ||
(input.goldBalance != null && formatSmsBedBes(input.goldBalance)
? `طلا ${formatSmsBedBes(input.goldBalance)}`
: '');
return { rialBalance: rial, metalBalance: metal };
}
export function renderSmsText(
text: string,
vars: { name?: string; phone?: string; title?: string; company?: string },
vars: {
name?: string;
phone?: string;
title?: string;
company?: string;
from?: string;
rialBalance?: string;
metalBalance?: string;
},
) {
return applyMessageTemplate(text, {
name: vars.name,
phone: vars.phone,
title: vars.title,
company: vars.company,
from: vars.from,
rialBalance: vars.rialBalance,
metalBalance: vars.metalBalance,
date: tehranJalaliLong(new Date()),
}).trim();
}
+67 -13
View File
@@ -56,7 +56,8 @@ import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text';
import { renderSmsText, smsBalanceTexts } from '../common/sms-text';
import { extractMandehRaw } from '../integrations/tahesab/tahesab-methods';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci, splitCsv } from '../common/search';
import {
@@ -763,6 +764,7 @@ export class PeopleService implements OnModuleInit {
lastName: true,
mobile: true,
phone: true,
position: true,
ownerId: true,
birthDate: true,
company: { select: { name: true } },
@@ -836,12 +838,15 @@ export class PeopleService implements OnModuleInit {
);
}
}
const message = renderSmsText(body, {
name: fullName,
phone: mobile,
title: fullName,
company: person.company?.name ?? '',
});
const message = renderSmsText(
body,
await this.personSmsVars(actor.organizationId, person.id, {
name: fullName,
phone: mobile,
title: person.position?.trim() || '',
company: person.company?.name ?? '',
}),
);
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
@@ -902,12 +907,15 @@ export class PeopleService implements OnModuleInit {
const person = await this.get(actor, personId);
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const message = renderSmsText(body, {
name: person.fullName,
phone: mobile,
title: person.fullName,
company: person.company?.name ?? '',
});
const message = renderSmsText(
body,
await this.personSmsVars(actor.organizationId, person.id, {
name: person.fullName,
phone: mobile,
title: person.position?.trim() || '',
company: person.company?.name ?? '',
}),
);
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
@@ -937,6 +945,52 @@ export class PeopleService implements OnModuleInit {
return { ok: true };
}
/** نام سازمان + مانده‌های کش‌شدهٔ ته‌حساب برای توکن‌های پیامک */
private async personSmsVars(
organizationId: string,
personId: string,
base: {
name: string;
phone: string;
title: string;
company: string;
},
) {
const [org, link] = await Promise.all([
this.prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
}),
this.prisma.personAccountingLink.findFirst({
where: { personId, connection: { organizationId } },
orderBy: [
{ connection: { isDefault: 'desc' } },
{ linkedAt: 'desc' },
],
include: { balanceCache: true },
}),
]);
let rialBalance = '';
let metalBalance = '';
const cache = link?.balanceCache;
if (cache) {
const rawView = extractMandehRaw(cache.rawResponse);
const bal = smsBalanceTexts({
mandehFields: rawView.fields,
rialBalance: cache.rialBalance,
goldBalance: cache.goldBalance,
});
rialBalance = bal.rialBalance;
metalBalance = bal.metalBalance;
}
return {
...base,
from: org?.name?.trim() || '',
rialBalance,
metalBalance,
};
}
listSmsHistory(actor: Actor, personId: string, limit?: number) {
return this.get(actor, personId).then(() =>
this.outboundSms.historyForPerson(actor.organizationId, personId, limit),
+6 -1
View File
@@ -1572,11 +1572,16 @@ export class LeadsService {
const mobile = lead.mobile || lead.phone;
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
const name = (lead.contactName || lead.title).trim();
const org = await this.prisma.organization.findUnique({
where: { id: actor.organizationId },
select: { name: true },
});
const message = renderSmsText(body, {
name,
phone: mobile,
title: lead.title,
company: lead.contactName ? lead.title : '',
company: lead.trade?.trim() || '',
from: org?.name?.trim() || '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
@@ -56,7 +56,19 @@ function testTemplate() {
}) === 'سلام علی 0912',
'name phone vars',
);
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
assert(
applyMessageTemplate('تبریک {از طرف}', { from: 'هنراندیشه' }) ===
'تبریک هنراندیشه',
'from org var',
);
assert(
applyMessageTemplate('مانده {مانده ریالی} / {مانده فلزی}', {
rialBalance: '۱۰۰۰ بس',
metalBalance: 'طلا ۲',
}) === 'مانده ۱۰۰۰ بس / طلا ۲',
'balance vars',
);
assert(clipSms('ا'.repeat(200)).length === 200, 'sms is not clipped');
}
testDelay();
@@ -77,6 +77,17 @@ export function buildSendAts(
return times;
}
/** یکسان‌سازی کلید توکن‌های قالب (فاصله/ZWNJ/ی عربی). */
export function normalizeTemplateKey(key: string): string {
return String(key ?? '')
.normalize('NFKC')
.replace(/[\u200c\u200d\u200e\u200f\ufeff]/g, '')
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\s+/g, ' ')
.trim();
}
export function applyMessageTemplate(
text: string,
vars: {
@@ -85,22 +96,44 @@ export function applyMessageTemplate(
phone?: string;
date?: string;
company?: string;
/** نام سازمان فرستنده */
from?: string;
/** مانده مالی قالب‌بندی‌شده */
rialBalance?: string;
/** مانده‌های وزنی/فلزی قالب‌بندی‌شده */
metalBalance?: string;
},
): string {
const map: Record<string, string> = {
title: vars.title ?? '',
عنوان: vars.title ?? '',
نام: vars.name ?? '',
name: vars.name ?? '',
شماره: vars.phone ?? '',
phone: vars.phone ?? '',
تاریخ: vars.date ?? '',
date: vars.date ?? '',
شرکت: vars.company ?? '',
company: vars.company ?? '',
};
const from = vars.from ?? '';
const rial = vars.rialBalance ?? '';
const metal = vars.metalBalance ?? '';
const entries: Array<[string, string]> = [
['title', vars.title ?? ''],
['عنوان', vars.title ?? ''],
['نام', vars.name ?? ''],
['name', vars.name ?? ''],
['شماره', vars.phone ?? ''],
['phone', vars.phone ?? ''],
['تاریخ', vars.date ?? ''],
['date', vars.date ?? ''],
['شرکت', vars.company ?? ''],
['company', vars.company ?? ''],
['از طرف', from],
['ازطرف', from],
['from', from],
['مانده ریالی', rial],
['ماندهریالی', rial],
['rialBalance', rial],
['مانده فلزی', metal],
['ماندهفلزی', metal],
['metalBalance', metal],
];
const map: Record<string, string> = {};
for (const [key, value] of entries) {
map[normalizeTemplateKey(key)] = value;
}
return text.replace(/\{([^}]+)\}/g, (full, key: string) => {
const next = map[key.trim()];
const next = map[normalizeTemplateKey(key)];
return next === undefined ? full : next;
});
}
@@ -356,6 +356,10 @@ export class MessageAutomationsService {
})
: null;
const lead = await this.lookupLead(job);
const org = await this.prisma.organization.findUnique({
where: { id: job.organizationId },
select: { name: true },
});
const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: '';
@@ -366,6 +370,7 @@ export class MessageAutomationsService {
phone,
date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '',
from: org?.name?.trim() || '',
};
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
const body = applyMessageTemplate(job.rule.body, vars);
@@ -511,7 +511,7 @@ export class SmsCampaignsService {
const leadIds = batch
.map((r) => r.leadId)
.filter((id): id is string => Boolean(id));
const [people, leads] = await Promise.all([
const [people, leads, org] = await Promise.all([
personIds.length
? this.prisma.person.findMany({
where: { id: { in: personIds } },
@@ -535,9 +535,14 @@ export class SmsCampaignsService {
},
})
: Promise.resolve([]),
this.prisma.organization.findUnique({
where: { id: campaign.organizationId },
select: { name: true },
}),
]);
const personMap = new Map(people.map((p) => [p.id, p]));
const leadMap = new Map(leads.map((l) => [l.id, l]));
const fromOrg = org?.name?.trim() || '';
let sent = 0;
let failed = 0;
@@ -572,6 +577,7 @@ export class SmsCampaignsService {
phone: recipient.mobile,
title,
company,
from: fromOrg,
});
const personalized = await this.tracking.personalizeSmsBody({
+4
View File
@@ -38,6 +38,10 @@ describe('اتوماسیون پیام', () => {
'سلام علی از منتکت - {unknown}',
);
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
assert.equal(
applyMessageTemplate('از طرف {از طرف}', { from: 'هنراندیشه' }),
'از طرف هنراندیشه',
);
});
it('متن پیامک بریده نمی‌شود', () => {