feat(sms): display full rendered SMS body in logs, streamline pattern triggers tab and remove legacy assignment dropdown
Some checks failed
Deploy Canina / deploy (push) Failing after 7s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 30s

This commit is contained in:
parsa aghaei 2026-09-05 09:38:51 +03:30
parent 2f41d4afe7
commit 09c8588401
2 changed files with 360 additions and 271 deletions

View File

@ -601,6 +601,35 @@ export class SmsService {
});
}
/**
* Render interpolated message text using pattern body template and arguments
*/
async renderPatternMessage(
patternId?: number | null,
args: string[] = [],
): Promise<string> {
if (!patternId) {
return args.join(' ');
}
try {
const patterns = await this.getPatterns();
const target = patterns.find((p) => p.id === patternId);
if (!target || !target.body) {
return args.length > 0 ? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}` : `الگو ${patternId}`;
}
let rendered = target.body;
args.forEach((val, idx) => {
const regex = new RegExp(`\\{${idx}\\}`, 'g');
rendered = rendered.replace(regex, val || '');
});
return rendered;
} catch {
return args.length > 0 ? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}` : `الگو ${patternId}`;
}
}
/**
* Fetch all registered patterns from MeliPayamak (with local cache sync)
*/
@ -639,90 +668,90 @@ export class SmsService {
}
}
// Load local stored patterns from settings table (fallback if offline or newly added locally)
// Load cached patterns from settings table
const storedSetting = await this.prisma.setting.findFirst({
where: { category: 'sms_patterns' },
where: {
category: 'sms_patterns',
key: { in: ['sms_patterns_cache', 'sms_patterns_config'] },
},
orderBy: { updatedAt: 'desc' },
});
const localPatterns: MeliPayamakPattern[] = Array.isArray(
const cachedPatterns: MeliPayamakPattern[] = Array.isArray(
storedSetting?.value,
)
? (storedSetting.value as unknown as MeliPayamakPattern[])
: [];
// Merge remote and local (remote takes precedence on ID match)
// Prioritize remote patterns, then cached patterns, then fallback defaults only if completely empty
const map = new Map<number, MeliPayamakPattern>();
// Add configured system patterns by default if map is empty
const defaultConfigs: Array<{
id: number;
title: string;
body: string;
assigned: string;
}> = [
{
id: config.otpBodyId,
title: 'کد تایید ورود و ثبت‌نام (OTP)',
body: 'کد ورود به کنینا: {0}',
assigned: 'otp',
},
{
id: config.orderBodyId,
title: 'تایید و ثبت فاکتور سفارش',
body: '{0} عزیز ، سفارش شما به شماره {1} به مبلغ {2} با موفقیت ثبت شد.\nکنینا ایران',
assigned: 'order',
},
{
id: config.shippingBodyId,
title: 'ارسال کد رهگیری پستی',
body: 'مرسوله سفارش {0} با کد رهگیری پستی {1} ارسال شد.',
assigned: 'shipping',
},
{
id: config.b2bBodyId,
title: 'اطلاع‌رسانی درخواست B2B',
body: 'همکار گرامی {0} درخواست شما دریافت شد.',
assigned: 'b2b',
},
];
for (const d of defaultConfigs) {
if (d.id > 0) {
map.set(d.id, {
id: d.id,
title: d.title,
body: d.body,
status: 1,
statusText: 'تایید شده',
assignedTo: [d.assigned],
});
// 1. If we have remote patterns, use them
for (const rp of remotePatterns) {
if (rp && rp.id) {
map.set(rp.id, { ...rp });
}
}
for (const lp of localPatterns) {
if (lp && lp.id) map.set(lp.id, { ...lp });
// 2. If remote returned empty or offline, use cached patterns
if (map.size === 0) {
for (const cp of cachedPatterns) {
if (cp && cp.id) {
map.set(cp.id, { ...cp });
}
}
}
for (const rp of remotePatterns) {
const existing = map.get(rp.id);
map.set(rp.id, {
...rp,
assignedTo: existing?.assignedTo || [],
});
// 3. Only if map is still completely empty (brand new install / unconfigured account), provide fallback defaults
if (map.size === 0) {
const defaultConfigs: Array<{
id: number;
title: string;
body: string;
}> = [
{
id: config.otpBodyId,
title: 'کد تایید ورود و ثبت‌نام (OTP)',
body: 'کد ورود به کنینا: {0}',
},
{
id: config.orderBodyId,
title: 'تایید و ثبت فاکتور سفارش',
body: '{0} عزیز ، سفارش شما به شماره {1} به مبلغ {2} با موفقیت ثبت شد.\nکنینا ایران',
},
{
id: config.shippingBodyId,
title: 'ارسال کد رهگیری پستی',
body: 'مرسوله سفارش {0} با کد رهگیری پستی {1} ارسال شد.',
},
{
id: config.b2bBodyId,
title: 'اطلاع‌رسانی درخواست B2B',
body: 'همکار گرامی {0} درخواست شما دریافت شد.',
},
];
for (const d of defaultConfigs) {
if (d.id > 0) {
map.set(d.id, {
id: d.id,
title: d.title,
body: d.body,
status: 1,
statusText: 'تایید شده',
assignedTo: [],
});
}
}
}
// Mark current bindings (both legacy bodyIds and dynamic scenario rules)
// Bind current scenario triggers and system assignments
const result = Array.from(map.values()).map((p) => {
const assigned: string[] = [];
if (p.id === config.otpBodyId) assigned.push('کد تایید OTP');
if (p.id === config.orderBodyId) assigned.push('پترن پیش‌فرض سفارش');
if (p.id === config.shippingBodyId) assigned.push('پترن پیش‌فرض پست');
if (p.id === config.b2bBodyId) assigned.push('پترن پیش‌فرض B2B');
if (p.id === config.petCareBodyId) assigned.push('پترن پیش‌فرض یادآور پت');
// Check dynamic scenario rules
// Check dynamic scenario rules (Primary Assignment)
if (Array.isArray(config.rules)) {
for (const r of config.rules) {
if (Number(r.patternId) === p.id) {
if (Number(r.patternId) === p.id && r.enabled) {
const ruleTag = `سناریو: ${r.title}`;
if (!assigned.includes(ruleTag)) {
assigned.push(ruleTag);
@ -731,6 +760,23 @@ export class SmsService {
}
}
// Check legacy default bindings
if (p.id === config.otpBodyId && !assigned.some((a) => a.includes('OTP'))) {
assigned.push('کد تایید OTP');
}
if (p.id === config.orderBodyId && !assigned.some((a) => a.includes('سفارش'))) {
assigned.push('پترن پیش‌فرض سفارش');
}
if (p.id === config.shippingBodyId && !assigned.some((a) => a.includes('پست'))) {
assigned.push('پترن پیش‌فرض پست');
}
if (p.id === config.b2bBodyId && !assigned.some((a) => a.includes('B2B'))) {
assigned.push('پترن پیش‌فرض B2B');
}
if (p.id === config.petCareBodyId && !assigned.some((a) => a.includes('پت'))) {
assigned.push('پترن پیش‌فرض پرونده پت');
}
return { ...p, assignedTo: assigned };
});
@ -982,88 +1028,95 @@ export class SmsService {
bodyId: options.bodyId,
});
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
// Pre-render message text for logging
void this.renderPatternMessage(options.bodyId, options.args).then((renderedText) => {
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
},
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => {
void (async () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
this.logger.log(
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
status: 'SUCCESS',
recId: String(val),
});
resolve(true);
} else {
const errMsg = `خطای درگاه ملی پیامک با کد بازگشتی: ${val}`;
this.logger.error(
`[SMS Error] Failed sending to ${options.to}. Code: ${val}`,
);
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => {
void (async () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
this.logger.log(
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'SUCCESS',
recId: String(val),
});
resolve(true);
} else {
const errMsg = `خطای درگاه ملی پیامک با کد بازگشتی: ${val}`;
this.logger.error(
`[SMS Error] Failed sending to ${options.to}. Code: ${val}`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'FAILED',
recId: String(val),
errorMessage: errMsg,
});
resolve(false);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'FAILED',
recId: String(val),
errorMessage: errMsg,
errorMessage: `خطای پارس پاسخ سرور: ${data || msg}`,
});
resolve(false);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
status: 'FAILED',
errorMessage: `خطای پارس پاسخ سرور: ${data || msg}`,
});
resolve(false);
}
})();
});
},
);
})();
});
},
);
req.on('error', (err: Error) => {
void (async () => {
this.logger.error(
`[SMS Exception] MeliPayamak HTTP Error: ${err.message}`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
status: 'FAILED',
errorMessage: `خطای شبکه: ${err.message}`,
});
resolve(false);
})();
req.on('error', (err: Error) => {
void (async () => {
this.logger.error(
`[SMS Exception] MeliPayamak HTTP Error: ${err.message}`,
);
await this.recordLog({
receptor: options.to,
type: msgType,
patternId: options.bodyId,
args: options.args,
messageText: renderedText,
status: 'FAILED',
errorMessage: `خطای شبکه: ${err.message}`,
});
resolve(false);
})();
});
req.write(payload);
req.end();
});
req.write(payload);
req.end();
});
}
@ -1106,89 +1159,95 @@ export class SmsService {
bodyId: bodyId,
});
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
void this.renderPatternMessage(bodyId, args).then((renderedText) => {
const req = https.request(
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
},
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => {
void (async () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
status: 'SUCCESS',
recId: String(val),
});
resolve({
success: true,
message: `پیامک تستی پترن با موفقیت ارسال شد (شناسه پیگیری ملی پیامک: ${val})`,
rawResponse: json,
});
} else {
const errMsg = `خطای درگاه ملی پیامک: کد پاسخ بازگشتی ${val}`;
(res) => {
let data = '';
res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
res.on('end', () => {
void (async () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'SUCCESS',
recId: String(val),
});
resolve({
success: true,
message: `پیامک تستی پترن با موفقیت ارسال شد (شناسه پیگیری ملی پیامک: ${val})`,
rawResponse: json,
});
} else {
const errMsg = `خطای درگاه ملی پیامک: کد پاسخ بازگشتی ${val}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'FAILED',
recId: String(val),
errorMessage: errMsg,
});
resolve({
success: false,
message: errMsg,
rawResponse: json,
});
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const errMsg = `پاسخ نامعتبر از سرور ملی پیامک: ${data || msg}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'FAILED',
recId: String(val),
errorMessage: errMsg,
});
resolve({
success: false,
message: errMsg,
rawResponse: json,
});
resolve({ success: false, message: errMsg });
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const errMsg = `پاسخ نامعتبر از سرور ملی پیامک: ${data || msg}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
status: 'FAILED',
errorMessage: errMsg,
});
resolve({ success: false, message: errMsg });
}
})();
});
},
);
})();
});
},
);
req.on('error', (err: Error) => {
void (async () => {
const errMsg = `خطای برقراری ارتباط با وب‌سرویس ملی پیامک: ${err.message}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
status: 'FAILED',
errorMessage: errMsg,
});
resolve({ success: false, message: errMsg });
})();
req.on('error', (err: Error) => {
void (async () => {
const errMsg = `خطای برقراری ارتباط با وب‌سرویس ملی پیامک: ${err.message}`;
await this.recordLog({
receptor: targetPhone,
type: 'TEST',
patternId: bodyId,
args,
messageText: renderedText,
status: 'FAILED',
errorMessage: errMsg,
});
resolve({ success: false, message: errMsg });
})();
});
req.write(payload);
req.end();
});
req.write(payload);
req.end();
});
}

View File

@ -122,7 +122,7 @@ const toPersianDigits = (n: string | number | null | undefined): string => {
};
export default function SmsSettingsPage() {
const [activeTab, setActiveTab] = useState<'settings' | 'triggers' | 'patterns' | 'logs'>('triggers');
const [activeTab, setActiveTab] = useState<'settings' | 'patterns' | 'triggers' | 'logs'>('patterns');
// Config State
const [config, setConfig] = useState<SmsConfigState>({
@ -729,24 +729,6 @@ export default function SmsSettingsPage() {
پیکربندی حساب
</button>
<button
type="button"
onClick={() => setActiveTab('triggers')}
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'triggers'
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Zap className="w-4 h-4 text-amber-500" />
سناریوهای پیامک (Triggers)
{config.rules && config.rules.length > 0 && (
<span className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
{config.rules.length}
</span>
)}
</button>
<button
type="button"
onClick={() => setActiveTab('patterns')}
@ -765,6 +747,24 @@ export default function SmsSettingsPage() {
)}
</button>
<button
type="button"
onClick={() => setActiveTab('triggers')}
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'triggers'
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Zap className="w-4 h-4 text-amber-500" />
سناریوهای پیامک (Triggers)
{config.rules && config.rules.length > 0 && (
<span className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
{config.rules.length}
</span>
)}
</button>
<button
type="button"
onClick={() => setActiveTab('logs')}
@ -1573,7 +1573,7 @@ export default function SmsSettingsPage() {
<th className="p-4">عنوان الگو</th>
<th className="p-4">متن الگو و متغیرها</th>
<th className="p-4">وضعیت تایید</th>
<th className="p-4">اتصال به سیستم</th>
<th className="p-4">سناریوهای متصل (Triggers)</th>
<th className="p-4 text-center">عملیات</th>
</tr>
</thead>
@ -1638,56 +1638,36 @@ export default function SmsSettingsPage() {
<td className="p-4 text-xs">
{p.assignedTo && p.assignedTo.length > 0 ? (
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1.5 max-w-xs">
{p.assignedTo.map((a, i) => (
<span
key={i}
className="bg-purple-100 text-purple-800 text-[10px] font-bold px-2 py-0.5 rounded-md"
className="inline-flex items-center gap-1 bg-purple-50 text-purple-800 text-[11px] font-bold px-2 py-0.5 rounded-lg border border-purple-200"
>
{a}
<Zap className="w-2.5 h-2.5 text-purple-600" />
<span>{a}</span>
</span>
))}
</div>
) : (
<span className="text-gray-400 text-[11px]">آزاد (بدون اتصال)</span>
<span className="text-gray-400 text-[11px] italic">آزاد (بدون سناریو)</span>
)}
</td>
<td className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 flex-wrap">
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={() => {
handleOpenAddRuleModal(p.id, 'ORDER_PAID', `ارسال الگو ${p.id} - ${p.title}`);
handleOpenAddRuleModal(p.id, 'ORDER_PAID', `ارسال با الگوی ${p.id} - ${p.title}`);
}}
className="px-2.5 py-1.5 bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-lg text-xs font-bold transition-colors border border-purple-200 flex items-center gap-1"
title="ایجاد سناریوی پیامک جدید با این الگو (مثلاً برای مشتری، مدیر، یا شرکت ارسال)"
className="px-3 py-1.5 bg-amber-50 text-amber-800 hover:bg-amber-100 rounded-xl text-xs font-bold transition-all border border-amber-200 flex items-center gap-1.5 shadow-2xs"
title="اتصال این الگو به یک سناریوی ارسال پیامک (مانند ارسال به مشتری، ادمین، انبار یا شخص خاص)"
>
<Plus className="w-3.5 h-3.5" />
<span>افزودن به سناریوها</span>
<Zap className="w-3.5 h-3.5 text-amber-600" />
<span>اتصال به سناریو</span>
</button>
<select
onChange={(e) => {
if (e.target.value) {
handleAssignPattern(e.target.value as keyof SmsConfigState, p.id);
e.target.value = '';
}
}}
defaultValue=""
className="text-[11px] font-bold bg-gray-50 border border-gray-200 rounded-lg p-1.5 text-gray-700 outline-none hover:border-purple-300"
title="تنظیم به عنوان الگوی پیش‌فرض سیستمی"
>
<option value="" disabled>
الگوی پیشفرض...
</option>
<option value="otpBodyId">پیش‌فرض: کد تایید OTP</option>
<option value="orderBodyId">پیش‌فرض: تایید ثبت سفارش</option>
<option value="shippingBodyId">پیش‌فرض: کد رهگیری پست</option>
<option value="b2bBodyId">پیش‌فرض: درخواست همکار B2B</option>
<option value="petCareBodyId">پیش‌فرض: یادآور پرونده سلامت پت</option>
</select>
<button
type="button"
onClick={() => {
@ -1695,7 +1675,7 @@ export default function SmsSettingsPage() {
setEditPatternBody(p.body);
setIsEditModalOpen(true);
}}
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-xl border border-gray-200 hover:border-blue-200 transition-all"
title="ویرایش متن الگو"
>
<Edit3 className="w-4 h-4" />
@ -1902,21 +1882,44 @@ export default function SmsSettingsPage() {
</span>
</td>
<td className="p-4 text-xs font-mono">
{log.patternId ? (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded font-bold">
#{log.patternId}
</span>
{log.args && log.args.length > 0 && (
<span className="text-gray-500 font-sans text-[11px]">
[{log.args.join(' , ')}]
</span>
)}
</div>
) : (
<span className="text-gray-400 font-sans">متنی آزاد</span>
)}
<td className="p-4 text-xs font-mono max-w-xs">
{(() => {
let snippet = log.messageText;
if (!snippet && log.patternId) {
const pat = patterns.find((p) => p.id === log.patternId);
if (pat && pat.body) {
let rendered = pat.body;
(log.args || []).forEach((val, idx) => {
rendered = rendered.replace(new RegExp(`\\{${idx}\\}`, 'g'), val || '');
});
snippet = rendered;
}
}
return (
<div>
{log.patternId ? (
<div className="flex items-center gap-1.5 flex-wrap mb-1">
<span className="bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded font-bold">
#{log.patternId}
</span>
{log.args && log.args.length > 0 && (
<span className="text-gray-500 font-sans text-[10px]">
[{log.args.join(' , ')}]
</span>
)}
</div>
) : (
<span className="text-gray-400 font-sans text-[11px] block mb-1">متنی مستقیم</span>
)}
{snippet && (
<p className="font-sans text-[11px] text-gray-600 line-clamp-1 truncate" title={snippet}>
{snippet}
</p>
)}
</div>
);
})()}
</td>
<td className="p-4 text-xs font-mono font-bold text-gray-700">
@ -2539,6 +2542,33 @@ export default function SmsSettingsPage() {
</div>
</div>
{/* Full SMS Message Text */}
{(() => {
let fullText = selectedLog.messageText;
if (!fullText && selectedLog.patternId) {
const pat = patterns.find((p) => p.id === selectedLog.patternId);
if (pat && pat.body) {
let rendered = pat.body;
(selectedLog.args || []).forEach((val, idx) => {
rendered = rendered.replace(new RegExp(`\\{${idx}\\}`, 'g'), val || '');
});
fullText = rendered;
}
}
return (
<div className="space-y-1">
<span className="font-bold text-gray-800 flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-purple-600" />
<span>متن کامل پیامک ارسالی:</span>
</span>
<div className="bg-purple-50/50 p-3.5 rounded-xl border border-purple-200/70 font-sans text-xs text-gray-800 leading-relaxed whitespace-pre-wrap select-text">
{fullText || (selectedLog.args && selectedLog.args.length > 0 ? selectedLog.args.join(' ') : 'متن ثبت نشده است.')}
</div>
</div>
);
})()}
{selectedLog.args && selectedLog.args.length > 0 && (
<div className="space-y-1">
<span className="font-bold text-gray-700">آرگومانها و مقادیر ارسالی به پترن:</span>