diff --git a/apps/api/src/common/sms-text.ts b/apps/api/src/common/sms-text.ts index d2c4302..2a35b23 100644 --- a/apps/api/src/common/sms-text.ts +++ b/apps/api/src/common/sms-text.ts @@ -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 = { + 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(); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 2366ed0..a82fd00 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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), diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index f693e13..c49cc81 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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({ diff --git a/apps/api/src/message-automations/__tests__/message-automation.check.ts b/apps/api/src/message-automations/__tests__/message-automation.check.ts index 7368eeb..6fd16a8 100644 --- a/apps/api/src/message-automations/__tests__/message-automation.check.ts +++ b/apps/api/src/message-automations/__tests__/message-automation.check.ts @@ -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(); diff --git a/apps/api/src/message-automations/message-automation.util.ts b/apps/api/src/message-automations/message-automation.util.ts index 4bbbd24..226f79b 100644 --- a/apps/api/src/message-automations/message-automation.util.ts +++ b/apps/api/src/message-automations/message-automation.util.ts @@ -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 = { - 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 = {}; + 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; }); } diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts index 4b4f0b3..68ff3e1 100644 --- a/apps/api/src/message-automations/message-automations.service.ts +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -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); diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index 28ee921..73ca8bf 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -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({ diff --git a/apps/api/test/message-automation.test.ts b/apps/api/test/message-automation.test.ts index a7dcb96..036b740 100644 --- a/apps/api/test/message-automation.test.ts +++ b/apps/api/test/message-automation.test.ts @@ -38,6 +38,10 @@ describe('اتوماسیون پیام', () => { 'سلام علی از منتکت - {unknown}', ); assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x'); + assert.equal( + applyMessageTemplate('از طرف {از طرف}', { from: 'هنراندیشه' }), + 'از طرف هنراندیشه', + ); }); it('متن پیامک بریده نمی‌شود', () => { diff --git a/apps/web/src/components/SmsComposeModal.vue b/apps/web/src/components/SmsComposeModal.vue index a4b9cd0..58f2f03 100644 --- a/apps/web/src/components/SmsComposeModal.vue +++ b/apps/web/src/components/SmsComposeModal.vue @@ -1,19 +1,23 @@