diff --git a/.gitignore b/.gitignore index a7a3594..440f834 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ data9 .venv .venv/* +# run.sh: pid files و لاگ‌های اجرای محلی +.run/ + # graphify: ignore regenerable cache and dated backups, keep the graph itself graphify-out/cache/ graphify-out/2026-*/ \ No newline at end of file diff --git a/0098sms.md b/0098sms.md new file mode 100644 index 0000000..82bd3a3 --- /dev/null +++ b/0098sms.md @@ -0,0 +1,296 @@ +## **راهنمای استفاده از لینک ارتباطی و وب‌سرویس سامانه پیامک (0098vip.com و 0098sms.com)** + +### **فهرست مطالب** + +* **مقدمات اتصال** (صفحه ۳) +* **راهنمای استفاده از لینک ارتباطی** (صفحه ۴) +* **کدهای خطای لینک ارتباطی** (صفحه ۵) +* **راهنمای استفاده از وب‌سرویس** (صفحه ۶) +* **کدهای خطای وب‌سرویس** (صفحه ۸) +* **نمونه کدها**: + * نمونه کدهای ASP (صفحه ۹) + * نمونه کدهای PHP (صفحه ۱۱) + * نمونه کد Python (صفحه ۱۴) + +### **مقدمات اتصال** + +جهت فعال‌سازی سرویس ابتدا وارد حساب کاربری خود در سامانه 0098vip شوید و از منوی سمت راست گزینه «لینک ارتباطی \- وب سرویس (otp)» را انتخاب نمایید: + +1. **مرحله اول (ورود به بخش مربوطه):** از منوی سمت راست پنل، بر روی گزینه «لینک ارتباطی \- وب سرویس (otp)» کلیک کنید. +2. **مرحله دوم (انتخاب شماره و فعال‌سازی):** شماره اختصاصی پنل خود را در بخش فعال‌سازی انتخاب کرده و دکمه «فعال‌سازی» را بزنید. +3. **مرحله سوم (انتخاب رمز وب‌سرویس):** در پایین صفحه و در بخش «انتخاب رمز برای لینک ارتباطی \- وب سرویس»، رمز اصلی ورود به پنل را وارد نموده و بر روی «ایجاد رمز وب سرویس» کلیک کنید. سامانه رمزی اختصاصی جهت اتصال به API و وب‌سرویس تولید می‌کند. + +> **نکته امنیتی:** پیشنهاد می‌شود از منوی تنظیمات، در بخش «IP های امن»، آدرس آی‌پی سرور خود را درج فرمایید تا ارسال پیامک منحصراً از سمت سرور شما مجاز باشد. + +### **راهنمای استفاده از لینک ارتباطی (URL GET)** + +آدرس پایه جهت فراخوانی لینک ارسال پیامک به شرح زیر است: + +Plaintext +https://0098vip.com/sendsmslink.aspx?FROM=$FROM\&TO=$TO\&TEXT=$TEXT\&USERNAME=$USERNAME\&PASSWORD=$PASSWORD\&DOMAIN=$DOMAIN + +پارامترهای متغیر در لینک: + +* FROM: شماره اختصاصی فرستنده متصل به لینک +* TO: شماره مقصد (گیرنده پیامک) +* TEXT: متن ارسالی پیامک +* USERNAME: نام کاربری ورود به پنل +* PASSWORD: رمز عبور اتصال +* DOMAIN: عبارت 0098 + +### **جدول کدهای خطای لینک ارتباطی** + +| کد خطا | شرح خطا | +| ----: | ----: | +| ۰ | عملیات با موفقیت به پایان رسید. | +| ۱ | شماره گیرنده اشتباه است. | +| ۲ | گیرنده تعریف نشده است. | +| ۳ | فرستنده تعریف نشده است. | +| ۴ | متن تنظیم نشده است. | +| ۵ | نام کاربری تنظیم نشده است. | +| ۶ | کلمه عبور تنظیم نشده است. | +| ۷ | نام دامین تنظیم نشده است. | +| ۸ | مجوز شما باطل شده است. | +| ۹ | اعتبار پیامک شما کافی نیست. | +| ۱۰ | برای این شماره لینک تعریف نشده است. | +| ۱۱ | عدم مجوز برای اتصال لینک. | +| ۱۲ | نام کاربری و کلمه عبور اشتباه است. | +| ۱۳ | کاراکتر غیر مجاز در متن وجود دارد. | +| ۱۴ | سقف ارسال روزانه پر شده است. | +| ۱۶ | عدم مجوز شماره برای ارسال از لینک. | +| ۱۷ | خطا در شماره پنل، لطفاً با پشتیبانی تماس بگیرید. | +| ۱۸ | اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود. | +| ۱۹ | تنظیمات کد opt انجام نشده است. | +| ۲۰ | فرمت کد opt صحیح نیست. | +| ۲۱ | تنظیمات کد opt توسط ادمین تایید نشده است. | +| ۲۲ | اطلاعات مالک شماره ثبت و تایید نشده است. | +| ۲۳ | هنوز اجازه ارسال به این شماره پنل داده نشده است. | +| ۲۴ | ارسال از IP غیر مجاز انجام شده است. | + +### **راهنمای استفاده از Web Service** + +آدرس وب‌سرویس سامانه: + +Plaintext +https://webservice.0098vip.com/service.asmx + +*نکته:* پس از افزودن ماژول وب‌سرویس، توصیه می‌شود Namespace اولیه را تغییر داده و نامی بدون نقطه (.) تنظیم کنید. +توابع وب‌سرویس: + +1. SendSMS: ارسال پیامک تکی به‌صورت آنی. +2. SendSMSWithTime: ارسال پیامک تکی زمان‌بندی‌شده (با قابلیت تعیین تاریخ و زمان). +3. SendSMSWithID: ارسال پیامک تکی همراه با بازگرداندن شناسه پیامک (smsid) جهت رهگیری وضعیت. +4. smsdeliveryState: استعلام وضعیت دلیوری پیامک ارسالی با استفاده از شناسه پیامک. +5. RemainSms: دریافت مانده موجودی پیامک‌های پنل. + +### **پارامترهای ورودی توابع وب‌سرویس** + +| نام پارامتر | نوع داده | توضیح | +| ----: | ----: | ----: | +| Username | String | نام کاربری پنل | +| password | String | پسورد پنل | +| text | String | متن پیامک | +| MobileNo | String | شماره موبایل گیرنده | +| PnlNo (PniNo) | String | شماره اختصاصی پنل در سامانه | +| Timestmp | String | تاریخ و زمان ارسال در قالب Timestamp (۱۰ رقمی) | +| smsid | String | شناسه پیامک (عدد حداقل ۹ رقمی در فرمت string) | + +### **جدول کدهای خطای وب‌سرویس و وضعیت‌های دلیوری** + +| کد برگشتی | توضیحات | +| ----: | ----: | +| ۰ | ارسال با موفقیت انجام شد. | +| ۲ (یا عدد ۹+ رقمی) | ارسال با موفقیت انجام شد (خروجی تابع دریافت شناسه/دلیوری). | +| ۴ | مخاطب دریافت پیام‌های تبلیغاتی خود را بسته است (بلک‌لیست). | +| ۵ | رسیده به گوشی. | +| ۶ | نرسیده به گوشی. | +| ۷ | وضعیت دریافت پیامک منقضی شده است. | +| ۸ | وضعیت پیامک نامشخص است. | +| ۹ | اطلاعات مالک شماره ثبت و تایید نشده است. | +| ۱۰ | عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید. | +| ۱۲ | هنوز اجازه ارسال به این شماره پنل داده نشده است. | +| ۱۳ | ارسال از IP غیر مجاز انجام شده است. | +| ۱۷ | نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی تماس بگیرید. | +| ۱۸ | کاراکتر غیر مجاز در متن وجود دارد. | +| ۱۹ | متن پیامک خالی است. | +| \-۲۲ | خطای شارژ؛ لطفاً با پشتیبانی تماس بگیرید. | +| \-23 | حساب کاربری شما مسدود است؛ لطفاً با پشتیبانی تماس بگیرید. | +| \-24 | مرحله دوم ثبت‌نام شما انجام نگرفته است؛ لطفاً ثبت‌نام را کامل نمایید. | +| \-25 | عدم مجوز شماره برای ارسال از لینک. | +| \-26 | خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید. | +| \-27 | اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود. | +| \-28 | تنظیمات کد opt انجام نشده است. | +| \-29 | فرمت کد opt صحیح نیست. | +| \-31 | smsid یافت نشد. | +| \-32 | smsid مطابقت ندارد. | +| ۶۶ | شارژ پنل شما برای ارسال کافی نیست؛ لطفاً اقدام به شارژ نمایید. | +| ۱۱۱۱ | شماره موبایل صحیح نیست. | +| Hang | عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید. | +| No Doc | کاراکتر غیر مجاز در متن وجود دارد. | +| Wrong timestamp | تایم‌استمپ ارسال‌شده صحیح نیست. | +| Wrong time | زمان در بازه صحیح نیست (تنظیم ارسال از همان روز تا ۱ سال بعد مجاز است). | + +### **نمونه کدهای پیاده‌سازی** + +#### **۱. نمونه کدهای C\# / ASP.NET** + +**ارسال پیامک ساده (SendSMS):** + +C\# +ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient(); +string username \= "username"; +string password \= "password"; +string smstext \= "smstext"; +string mobileno \= "091XXXXXXXX"; +string panelno \= "3000XXXX"; +string responsecode \= ss.SendSMS(username, password, smstext, mobileno, panelno); + +**ارسال پیامک زمان‌دار (SendSMSWithTime):** + +C\# +ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient(); +string username \= "username"; +string password \= "password"; +string smstext \= "smstext"; +string mobileno \= "091XXXXXXXX"; +string panelno \= "3000XXXX"; +string timestamp \= "1658939130"; +string responsecode \= ss.SendSMSwithtime(username, password, smstext, mobileno, panelno, timestamp); + +**ارسال پیامک با دریافت شناسه (SendSMSWithID):** + +C\# +ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient(); +string username \= "username"; +string password \= "password"; +string smstext \= "smstext"; +string mobileno \= "091XXXXXXXX"; +string panelno \= "3000XXXX"; +string responsecode \= ss.SendSMSWithID(username, password, smstext, mobileno, panelno); + +**استعلام دلیوری (smsdeliveryState):** + +C\# +ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient(); +string username \= "username"; +string password \= "password"; +string smsid \= "XXXXXXXXXX"; +string deliverystate \= ss.smsdeliveryState(username, password, smsid); + +**استعلام مانده اعتبار (RemainSms):** + +C\# +ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient(); +string username \= "username"; +string password \= "password"; +string remain \= ss.RemainSms(username, password); + +#### **۲. نمونه کدهای PHP** + +**ارسال ساده (SendSMS):** + +PHP +\'UTF-8')); + +$parameters\['username'\] \= "username"; +$parameters\['password'\] \= "password"; +$parameters\['mobileno'\] \= "0912XXXXXXXX"; +$parameters\['pnlno'\] \= "3000XXXX"; +$parameters\['text'\] \= "test"; +$parameters\['isflash'\] \= false; + +echo $sms\_client\-\>SendSMS($parameters)-\>SendSMSResult; +?\> + +**ارسال زمان‌دار (SendSMSWithTime):** + +PHP +\'UTF-8')); + +$parameters\['username'\] \= "username"; +$parameters\['password'\] \= "password"; +$parameters\['mobileno'\] \= "0912XXXXXXXX"; +$parameters\['pnlno'\] \= "3000XXXX"; +$parameters\['text'\] \= "test"; +$parameters\['timestamp'\] \= "1658939130"; +$parameters\['isflash'\] \= false; + +echo $sms\_client\-\>SendSMSwithtime($parameters)-\>SendSMSWithTimeResult; +?\> + +**ارسال با دریافت شناسه (SendSMSWithID):** + +PHP +\'UTF-8')); + +$parameters\['username'\] \= "username"; +$parameters\['password'\] \= "password"; +$parameters\['mobileno'\] \= "0912XXXXXXXX"; +$parameters\['pnlno'\] \= "3000XXXX"; +$parameters\['text'\] \= "test"; +$parameters\['isflash'\] \= false; + +echo $sms\_client\-\>SendSMSWithID($parameters)-\>SendSMSWithIDResult; +?\> + +**استعلام دلیوری (smsdeliveryState):** + +PHP +\'UTF-8')); + +$parameters\['username'\] \= "username"; +$parameters\['password'\] \= "password"; +$parameters\['smsid'\] \= "XXXXXXXXXX"; +$parameters\['isflash'\] \= false; + +echo $sms\_client\-\>smsdeliveryState($parameters)-\>smsdeliveryStateResult; +?\> + +**استعلام مانده اعتبار (RemainSms):** + +PHP +\'UTF-8')); + +$parameters\['username'\] \= "username"; +$parameters\['password'\] \= "password"; +$parameters\['isflash'\] \= false; + +echo $sms\_client\-\>RemainSms($parameters)-\>remainResult; +?\> + +#### **۳. نمونه کد Python** + +ارسال ساده با کتابخانه Zeep: + +Python +from zeep import Client + +client \= Client('https://webservice.0098vip.com/service.asmx?WSDL') + +parameters \= { + 'username': 'your\_username', + 'password': 'your\_password', + 'mobileno': '0912XXXXXXX', + 'panelno': '3000XXXXXXX', + 'text': 'test', + 'isflash': False +} + +response \= client.service.SendSMS(parameters) +print("SendSMSResult:", response) + +**منابع** +1\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter) +2\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter) +3\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter) \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 2fa0454..9fc0650 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -1,11 +1,11 @@ Project Root: khatibi -Generated At: 2026-10-09T15:40:01+00:00 -Python Files: 126 -Django Files: 16 +Generated At: 2026-10-10T06:32:47+00:00 +Python Files: 153 +Django Files: 12 JavaScript/TypeScript Files: 14 -HTML Files: 50 -Total Files: 447 -Total Directories: 71 +HTML Files: 54 +Total Files: 523 +Total Directories: 74 khatibi ├── .cursor @@ -19,6 +19,7 @@ khatibi │ ├── plugins │ │ └── graphify.js │ └── opencode.json +├── .run ├── admin │ ├── migrations │ │ └── __init__.py @@ -35,6 +36,8 @@ khatibi │ │ │ ├── __init__.py │ │ │ ├── gt_esh.py │ │ │ ├── send_daily_report.py +│ │ │ ├── sms_scan.py +│ │ │ ├── sms_worker.py │ │ │ └── sqlite_db_backup.py │ │ └── __init__.py │ ├── migrations @@ -94,6 +97,12 @@ khatibi │ │ ├── 0054_auto_20260715_2250.py │ │ ├── 0055_action_nextnazardt.py │ │ ├── 0056_auto_20260731_1824.py +│ │ ├── 0057_sms.py +│ │ ├── 0058_seed_sms_templates.py +│ │ ├── 0059_sms_flow_control.py +│ │ ├── 0060_sms_permission_label.py +│ │ ├── 0061_sms_timing_and_primary.py +│ │ ├── 0062_sms_link_provider.py │ │ └── __init__.py │ ├── models │ │ ├── __init__.py @@ -102,9 +111,19 @@ khatibi │ │ ├── factors.py │ │ ├── finance.py │ │ ├── products.py +│ │ ├── sms.py │ │ ├── store.py │ │ ├── system.py │ │ └── users.py +│ ├── sms +│ │ ├── __init__.py +│ │ ├── enqueue.py +│ │ ├── phones.py +│ │ ├── recipients.py +│ │ ├── render.py +│ │ ├── rules.py +│ │ ├── scanner.py +│ │ └── timing.py │ ├── static │ │ └── admin_panel │ │ ├── DataTables @@ -189,6 +208,7 @@ khatibi │ │ ├── customer │ │ │ ├── customerNazarInc.html │ │ │ └── nazar.html +│ │ ├── _customer_sms.html │ │ ├── ActionList.html │ │ ├── base.html │ │ ├── baseHesabdari.html @@ -232,6 +252,9 @@ khatibi │ │ ├── report_rezayat.html │ │ ├── reportCheckNesiye.html │ │ ├── rezayat.html +│ │ ├── sms_queue.html +│ │ ├── sms_settings.html +│ │ ├── sms_templates.html │ │ ├── store.html │ │ ├── store_transfers.html │ │ ├── storeBK1.html @@ -249,26 +272,18 @@ khatibi │ │ ├── customers.py │ │ ├── factors.py │ │ ├── finance.py +│ │ ├── sms.py │ │ └── store.py │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── context_processor.py │ ├── services.py +│ ├── signals.py │ ├── tests.py │ ├── urls.py │ ├── views - Copy.py │ └── viewsBK.py -├── backups -│ └── refactor-2026-10-09 -│ ├── admin.py -│ ├── apps.py -│ ├── context_processor.py -│ ├── MANIFEST.sha256 -│ ├── models.py -│ ├── services.py -│ ├── urls.py -│ └── views.py ├── data9 │ ├── db2 (2).sqlite32 │ ├── db2.sqlite32 @@ -293,6 +308,7 @@ khatibi │ │ │ ├── 056f0cb55bfd5a1ddf744c7c824c342048c1db47f3bb350030a1533ab56bf8d7.json │ │ │ ├── 0b8f8e3b6e4d84256e784dabb96f5646fa6e7295adbd20648c235e7821b4ca1d.json │ │ │ ├── 0e6bfa570caca1f79f549f5cdc37f8bdf9c9b43a023ca28a8fcb83351389b399.json +│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json │ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json │ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json │ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json @@ -300,13 +316,19 @@ khatibi │ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json │ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json │ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json +│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json +│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json │ │ │ ├── 1dd0c6958a307a1e4e59c24f20225292277585dd8652b35537ede94fc04807ee.json │ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json │ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json │ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json │ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json │ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json +│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json +│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json │ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json +│ │ │ ├── 2f36d1565911fdcb414da716162085b0fdc4b92d9d2d3fbb9fecc403b1916766.json +│ │ │ ├── 338e7d5d2e7d3a7260ab74d9b7b19bbab6b9ce84e7036623060190d9e0cc4bcd.json │ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json │ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json │ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json @@ -319,7 +341,10 @@ khatibi │ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json │ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json │ │ │ ├── 58bfae9cf8467d2bb9363884d517e98cae38a934f4c474e9e02aed520fdaab95.json +│ │ │ ├── 58f2e3419fb2de4a3bdc4a9c0bf48e50f2cc225506096109dbf2b9bfe4778bb5.json │ │ │ ├── 5901400fc36893a9ad85acd97eb73b691cb4b8d1440047c5f85c1534a2c7e492.json +│ │ │ ├── 5b63b5d7fc7a09b2dbf5e2f7db6fe00ccab773762b3291bd666c8e29d32100d3.json +│ │ │ ├── 5ce9ee869c507986bf00ae6929f3aae3ab7aa58b5c2bded4965374fcf7f5654b.json │ │ │ ├── 5d5b6249e3cfa0aeee94ba2053fc73b4b7b5b8fa1d21df09d40bda7c90ef273d.json │ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json │ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json @@ -328,8 +353,11 @@ khatibi │ │ │ ├── 64fc04d8c553b2121a71955d48257160a40f6dd34258e3287945873f02d897dc.json │ │ │ ├── 65d5139792e0ee840c5b1710d7659ecfb5dbdcadbd02d205f7517a6a3a374211.json │ │ │ ├── 6876f75a9cb4a5f5857c47e3b0d6b38e51941aff76bc6d92cb1b4eef1c51071e.json +│ │ │ ├── 6a88f82e5b9f9da12b50f8531eac30c10e1c2a0cf39601d275fd90ee3e28da55.json +│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json │ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json │ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json +│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json │ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json │ │ │ ├── 71a07328d56403d55714d76cefc05db3e68c6498719e629e4e76889a1940efc2.json │ │ │ ├── 73a129efd08cc38274f3c56503c234eef17f58faf74dadbdbbe475efcbfc2777.json @@ -338,24 +366,38 @@ khatibi │ │ │ ├── 75d0862c4a7be01c37a92f0baf858c27367f6b242254080cccc5888a758dd0ff.json │ │ │ ├── 760b5e6a8ce6ddc3b34aae59a11c57c36471f9eb93f8d2e24c7a030c8ad02dbc.json │ │ │ ├── 761cb648c1272d981720580035fca3a501c423ab819648863f223b83292b4e58.json +│ │ │ ├── 767dd58146a5d4a713611561a9e5c1b706e5f509a5621891c9b2e195f3b8ee0a.json +│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json │ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json │ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json +│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json +│ │ │ ├── 7e9bad82d0eb642b739dcf11e86c2fcf2005dfb12c94478af143deb9a8129bfa.json │ │ │ ├── 8055bf9fe36a9fa183d0c5dc152684e65410d58c5634e544e8f38f6c9216f6ad.json │ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json │ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json +│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json │ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json │ │ │ ├── 86e57806cd08cf765ce1f56f0638dd321e93c653bb6613aa35d7ad375808433c.json │ │ │ ├── 88e349e879d601e1f5c75f3b019ffb4958e90639317ba893c54f82b0cc9066c2.json │ │ │ ├── 8ffb83ce6f13d2f97d0197c7381748e9b66956c13f0158bc519350b7dd1b9ad0.json +│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json +│ │ │ ├── 940d0349821b0fb7f2c3effcb81fd13bb1c046daa6f6c5f2cd1b157058c55877.json │ │ │ ├── 957f37be1d5d6a5207f90ad07704f8423c915c5bcdfcd130acc85a388fc89d19.json │ │ │ ├── 96c3ba545db191ae5811d5dfdde74e7c6396a932b759513c94160cb7b7c81fc8.json +│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json +│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json +│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json │ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json │ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json │ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json │ │ │ ├── a823c9c28027c8d525714e6571bc3f3ca38f3bd0420aaf4f4921067cf0672475.json │ │ │ ├── a8311f6e51027ea4606e35bf14d50edc1795e71b2d63cddeae8c7bb7ece215b8.json +│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json │ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json +│ │ │ ├── b26c22033963df1727407c1fa6c79d8241bfc16056e7a8d3c4383fb2d050d3cd.json │ │ │ ├── b26e251bde84a040dbb2d1a4f700ebcd761f51a124a98a104704e82537e320eb.json +│ │ │ ├── b34cb0a1a28e753a900b3d23f01d3810f9c06635b0650d34be4ec9d98daa8fbb.json +│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json │ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json │ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json │ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json @@ -368,24 +410,33 @@ khatibi │ │ │ ├── c72226544540cc75217ba02f1d09d989eb810d879409ac086c76245f27220cfa.json │ │ │ ├── c795f43bd1f10cf666ff987d18af538b2164c1683629ac689f0a54cb875118be.json │ │ │ ├── c7eb97fa16701c6d9ee531fa032479a7c771292f3e9d8921d3ea12a4897fd43a.json +│ │ │ ├── c846b5de286163b5eed16ff6d6e00f2038f30bf7edd2ab3f8d831369e190ae18.json +│ │ │ ├── cdc5059cf09416d24d03fe57a48af5219727866c84b8109d986456fbaa789dc4.json │ │ │ ├── cf6668c54cd4f65d268a17df44f100e1cb77f91eae90dd2dc052564731b2f195.json +│ │ │ ├── d1289d5406d360e3ff617e213db4e262cd43a32711a61aba97bdcd0a906cbcb5.json │ │ │ ├── d3456cd690135f9630e6b0bb79fef5d205804c7acabffc61cbd5ac4032b17bc7.json │ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json +│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json │ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json +│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json │ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json │ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json │ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json │ │ │ ├── e3ee8d9942c74a74be83ca00d5438e4a1e1cb5b23f55d95d308a42954e4757f0.json +│ │ │ ├── e9589ded446a5befb285a0a2e93a182ca9431c76661e68c9210ed252580dd7f0.json │ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json │ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json │ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json +│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json │ │ │ ├── ee557c6ed214858651cd3ebceb5e0fd00999354a4c54bb379f26c64b48b509ab.json │ │ │ ├── efd113025c3fb3c0becaecd8090e8494b13d92ff3c49220d54665b3a8af586af.json │ │ │ ├── f1af209a02f2ec5c5a344e3fa7dd8ad7d443e0479e55f5465449c0a2eed314a1.json │ │ │ ├── f3a6285dcf9eb39df500bc0b76b03b5941103e8b90c06ffaeab1b3129f18fedc.json │ │ │ ├── f537b36e2ee33d1303c10e950a6910cc7fd954c7fce3220be2c95d75d70dfd40.json │ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json -│ │ │ └── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json +│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json +│ │ │ ├── fd714e03c631694d30457ac529d05d8d131c183665e086246da607f752dea5eb.json +│ │ │ └── ff862e0ffad7bba911716072ae82f268240d82105ffefe78c7714a160c4c31ba.json │ │ ├── semantic │ │ │ └── p5e80268fecd6 │ │ │ ├── 0c4f87b180cd5b0576b197ff5cb0e9cc5dfd8d82d97d618036df00c7955fdbac.json @@ -451,6 +502,7 @@ khatibi │ │ │ ├── 7edbffbd4455d071df034927a69faaf76f8e8bbf6495921ce2b70eea1abe8e4f.json │ │ │ ├── 8a79c80fca0ed04957e7b4df749ab80b5d0f4ce3d0ae032a47d51d4a2fbfcd82.json │ │ │ ├── 8f60bda631cbe7457886d1d574b2e3d2850034f469592a3e8c333f0623a2d3c6.json +│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json │ │ │ ├── 92e1d82ed4571249c09163413ceae360dd4d7d03c162c554f3b5a47e94474cf1.json │ │ │ ├── 9897fa1997a41e155ed8de922722238e1f684f5c77a7f56405c8ac7cd1aa376f.json │ │ │ ├── 9b674bec71dfe6e133af9fb4e35a421fa56cecd230544008ed83a44e6eaae3bf.json @@ -466,6 +518,7 @@ khatibi │ │ │ ├── c97d49fed39b9aca8dea35a49c3e031a641420e4452e61d1ea2a912e899f6dd9.json │ │ │ ├── cacf6773249daafca21d21004106209d86aa215140de071c64363aff4d9e5431.json │ │ │ ├── cfb57017bc764ee7e9011b52f1e44ad2f41b2670778af73232ecfc309b406a0d.json +│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json │ │ │ ├── dc89fcddf8c6f20bc9f52ae15e5cc8b226320d2776dc8f27be16894e53165013.json │ │ │ ├── dd78862d0f7862653c702e86f7acfabfe33ec5d85d40ba764138c0d1784f7b06.json │ │ │ ├── de67f63e3f49d4ee32811dd67676da33a1e584f671d396fca130f3681d54bd76.json @@ -484,10 +537,31 @@ khatibi │ ├── .graphify_labels.json.sig │ ├── .graphify_root │ ├── .graphify_semantic_marker +│ ├── backup-graph-preibackupignore-20261009-191817.json +│ ├── backup-graph-presemantic-20261009-193633.json +│ ├── backup-graph-preupdate-20261009-191412.json +│ ├── backup-manifest-20261009-194120.json │ ├── graph.html │ ├── graph.json │ ├── GRAPH_REPORT.md │ └── manifest.json +├── integrations +│ ├── bale +│ │ ├── __init__.py +│ │ └── providers.py +│ ├── sms +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── behin.py +│ │ ├── console.py +│ │ ├── factory.py +│ │ ├── http.py +│ │ ├── normalize.py +│ │ ├── providers.py +│ │ └── sms0098.py +│ ├── __init__.py +│ ├── announcements.py +│ └── voip.py ├── khatibi │ ├── __init__.py │ ├── asgi.py @@ -504,6 +578,7 @@ khatibi │ ├── test_factors.py │ ├── test_inventory.py │ ├── test_models_contract.py +│ ├── test_sms.py │ ├── test_urls_contract.py │ └── test_view_smoke.py ├── .dockerignore @@ -512,6 +587,7 @@ khatibi ├── AGENTS.md ├── check_finance.py ├── db.json +├── docker-compose.yml ├── Dockerfile ├── entrypoint.sh ├── financial_audit_20260816_151225.csv @@ -525,4 +601,7 @@ khatibi ├── reconciliation_tool.py ├── requirements.txt ├── requirements2.txt -└── ripwire - README.md +├── ripwire - README.md +├── run.sh +├── screencapture-localhost-8000-panel-sms-templates-2026-10-09-23_01_18.png +└── stop.sh diff --git a/admin_panel/admin.py b/admin_panel/admin.py index 33abdbe..307986b 100644 --- a/admin_panel/admin.py +++ b/admin_panel/admin.py @@ -74,4 +74,104 @@ class StoreRoomAdmin(admin.ModelAdmin): def get_owners_count(self, obj): """نمایش تعداد مالکین در لیست اصلی انبارها""" return obj.owners.count() - get_owners_count.short_description = "تعداد مالکین" \ No newline at end of file + get_owners_count.short_description = "تعداد مالکین" + +# ============================================================ +# مدیریت پیامک (SMS) +# ============================================================ +from django import forms +from django.contrib import messages + + +class SMSProviderConfigForm(forms.ModelForm): + class Meta: + model = SMSProviderConfig + fields = '__all__' + widgets = { + 'password': forms.PasswordInput(render_value=True), + 'api_token': forms.PasswordInput(render_value=True), + } + + +@admin.register(SMSProviderConfig) +class SMSProviderConfigAdmin(admin.ModelAdmin): + form = SMSProviderConfigForm + list_display = ('provider', 'is_enabled', 'sender', 'username', + 'allow_backlog', 'backlog_max', 'max_per_run', + 'send_interval_seconds', 'updated_at') + readonly_fields = ('updated_at',) + fieldsets = ( + ("اتصال سرویس‌دهنده", { + 'fields': ('provider', 'is_enabled', 'api_url', 'username', 'password', + 'sender', 'api_token', 'test_message', 'timeout_seconds'), + }), + ("وب‌سرویس ۰۰۹۸ (فقط provider وب‌سرویس)", { + 'fields': ('webservice_url', 'panel_no'), + }), + ("کنترل جریان ارسال", { + 'fields': ('scan_since', 'allow_backlog', 'backlog_max', + 'max_per_run', 'send_interval_seconds', 'updated_at'), + }), + ) + + def has_add_permission(self, request): + # سینگلتون: فقط ویرایش رکورد موجود. + return not SMSProviderConfig.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(SMSTemplate) +class SMSTemplateAdmin(admin.ModelAdmin): + list_display = ('title', 'sms_type', 'is_enabled', 'auto_send', 'offset_days', + 'repeat_interval_days', 'repeat_interval_months') + list_editable = ('is_enabled', 'auto_send') + list_filter = ('sms_type', 'is_enabled', 'auto_send') + search_fields = ('title', 'text') + readonly_fields = ('updated_at',) + + +@admin.register(CustomerSMSSetting) +class CustomerSMSSettingAdmin(admin.ModelAdmin): + list_display = ('customer', 'sms_type', 'is_enabled') + list_filter = ('sms_type', 'is_enabled') + raw_id_fields = ('customer',) + search_fields = ('customer__name_family',) + + +@admin.register(SMSQueue) +class SMSQueueAdmin(admin.ModelAdmin): + list_display = ('id', 'customer', 'sms_type', 'phone_number', 'status', + 'delivery_status', 'attempts', 'created_at', 'sent_at') + list_filter = ('status', 'sms_type', 'delivery_status') + raw_id_fields = ('customer', 'created_by') + search_fields = ('phone_number', 'message', 'dedup_key') + readonly_fields = ('created_at', 'updated_at', 'sent_at') + actions = ['approve_selected', 'retry_selected'] + + @admin.action(description="تأیید موارد انتخاب‌شده (ارسال توسط worker)") + def approve_selected(self, request, queryset): + updated = queryset.filter(status=SMSQueue.Status.PENDING).update( + status=SMSQueue.Status.APPROVED, next_retry_at=None, + ) + self.message_user(request, f"{updated} پیامک تأیید شد.", messages.SUCCESS) + + @admin.action(description="ارسال مجدد موارد انتخاب‌شده") + def retry_selected(self, request, queryset): + updated = queryset.update(status=SMSQueue.Status.APPROVED, next_retry_at=None, last_error='') + self.message_user(request, f"{updated} پیامک برای ارسال مجدد تأیید شد.", messages.SUCCESS) + + +@admin.register(SMSLog) +class SMSLogAdmin(admin.ModelAdmin): + list_display = ('id', 'queue', 'attempt_no', 'success', 'provider_code', 'created_at') + list_filter = ('success',) + raw_id_fields = ('queue',) + readonly_fields = ('queue', 'attempt_no', 'success', 'provider_id', 'provider_code', 'error', 'created_at') + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/admin_panel/apps.py b/admin_panel/apps.py index c025e01..a6e75cf 100644 --- a/admin_panel/apps.py +++ b/admin_panel/apps.py @@ -1,6 +1,39 @@ +import sys + from django.apps import AppConfig +def make_std_streams_unicode_safe(): + """استریم‌های استاندارد را در برابر متن غیر-ASCII مقاوم می‌کند. + + کنسول ویندوز با کدپیج cp1252 کار می‌کند و نوشتن متن فارسی روی آن + ``UnicodeEncodeError`` می‌دهد (پیام خطا: ``'charmap' codec can't encode ...``). + + رخداد واقعی که این تابع برایش نوشته شد: ``print`` متن فارسی داخل provider کنسولِ + پیامک استثنا می‌داد، worker آن را به‌عنوان «ارسال ناموفق» ثبت می‌کرد و پیامک در صف + ``failed`` می‌ماند — در حالی که ارسال هیچ مشکلی نداشت. یک استثنای صرفاً نمایشی + نباید هرگز نتیجهٔ یک عملیات کسب‌وکار را عوض کند. + + پس اینجا یک‌بار برای همیشه استریم‌ها به UTF-8 با ``errors='replace'`` تنظیم می‌شوند: + هیچ چاپ فارسی‌ای پروسه را نمی‌شکند و فایل‌های لاگ هم فارسی را درست نگه می‌دارند. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + # جریان‌های جایگزین (مثلاً test runner یا jupyter) ممکن است پشتیبانی نکنند. + pass + + class AdminPanelConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'admin_panel' + + def ready(self): + # پیش از هر کامند/سیگنال: چاپ امن متن فارسی روی کنسول ویندوز. + make_std_streams_unicode_safe() + # ثبت سیگنال‌ها (busy_timeout برای SQLite). + from . import signals # noqa: F401 diff --git a/admin_panel/context_processor.py b/admin_panel/context_processor.py index 39af797..edebdf3 100644 --- a/admin_panel/context_processor.py +++ b/admin_panel/context_processor.py @@ -3,6 +3,7 @@ import os import datetime from .models import * +from .sms.notices import resolve as resolve_sms_notice def dashBoxData(request): @@ -52,5 +53,8 @@ def dashBoxData(request): 'entezarbazdid': entezarbazdid, 'bazdid': bazdid } - + + # پیام نتیجهٔ عملیات پیامک (جایگزین flash message) از ?notice=. + data.update(resolve_sms_notice(request)) + return data \ No newline at end of file diff --git a/admin_panel/management/commands/fix_customer_phones.py b/admin_panel/management/commands/fix_customer_phones.py new file mode 100644 index 0000000..1635e49 --- /dev/null +++ b/admin_panel/management/commands/fix_customer_phones.py @@ -0,0 +1,30 @@ +"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن مشتریان (phone1/phone2/phone3). + +روی دیتابیس production (سرور اصلی) هم قابل اجرا است؛ idempotent است و اجرای +دوباره‌اش تغییری ایجاد نمی‌کند. + + python manage.py fix_customer_phones --dry-run # فقط گزارش، بدون تغییر + python manage.py fix_customer_phones # اجرای واقعی +""" +from django.core.management.base import BaseCommand + +from admin_panel.sms.phone_cleanup import clean_all + + +class Command(BaseCommand): + help = "استخراج و استانداردسازی شماره‌های قدیمی مشتریان؛ متن‌های حذف‌شده در فیلد توضیحات می‌مانند." + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", action="store_true", + help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ چیزی ذخیره نکن.", + ) + + def handle(self, *args, **options): + dry = options["dry_run"] + stats = clean_all(dry_run=dry) + prefix = "شبیه‌سازی (بدون تغییر): " if dry else "پاک‌سازی انجام شد: " + self.stdout.write( + f"{prefix}{stats['customers']} مشتری، {stats['fields']} فیلد اصلاح شد، " + f"{stats['noted']} یادداشت ثبت شد." + ) diff --git a/admin_panel/management/commands/sms_delivery.py b/admin_panel/management/commands/sms_delivery.py new file mode 100644 index 0000000..8ba1e46 --- /dev/null +++ b/admin_panel/management/commands/sms_delivery.py @@ -0,0 +1,118 @@ +"""استعلام وضعیت تحقق پیامک‌های ارسال‌شده (رسیده/نرسیده به گوشی). + +فقط برای provider وب‌سرویس 0098SMS معنا دارد؛ تنها آن provider با +``SendSMSWithID`` شناسهٔ پیامک (smsid) برمی‌گرداند و ``smsdeliveryState`` +وضعیت تحویل را می‌دهد. برای سایر providerها دستور no-op است. +""" +import logging +import signal +import time +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.utils import timezone + +from admin_panel.models import SMSProviderConfig, SMSQueue +from integrations.sms.factory import get_sms_provider + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "استعلام وضعیت تحویل پیامک‌های ارسال‌شده از وب‌سرویس 0098SMS." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._stop = False + + def add_arguments(self, parser): + parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج") + parser.add_argument("--sleep", type=float, default=30.0, help="فاصلهٔ بین دورها (ثانیه)") + parser.add_argument("--batch", type=int, default=100, help="تعداد ردیف در هر دور") + parser.add_argument( + "--min-age", type=int, default=60, + help="حداقل فاصله (ثانیه) از زمان ارسال پیش از نخستین استعلام", + ) + + def handle(self, *args, **options): + signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) + once = options["once"] + sleep_seconds = options["sleep"] + + self._emit("استعلام دلیوری پیامک آغاز شد.", style=self.style.SUCCESS) + while not self._stop: + try: + processed = self._drain_batch( + options["batch"], options["min_age"], + ) + except Exception: + logger.exception("خطای غیرمنتظره در یک دور از استعلام دلیوری؛ دور بعد ادامه می‌دهد.") + processed = 0 + if once: + break + if processed == 0: + self._sleep_interruptible(sleep_seconds) + self._emit("استعلام دلیوری پیامک متوقف شد.", style=self.style.SUCCESS) + + # ------------------------------------------------------------------ helpers + def _handle_signal(self, signum, frame): + self._stop = True + + def _emit(self, text, *, style=None, err=False): + """چاپ با تحمل کدگذاری محدود کنسول (مثل worker پیامک).""" + stream = self.stderr if err else self.stdout + style = style or (lambda value: value) + for payload in (text, text.encode("ascii", "replace").decode("ascii")): + try: + stream.write(style(payload)) + return + except (UnicodeEncodeError, ValueError, OSError): + continue + + def _sleep_interruptible(self, seconds): + deadline = time.monotonic() + max(0, seconds) + while not self._stop and time.monotonic() < deadline: + time.sleep(min(0.5, deadline - time.monotonic())) + + def _drain_batch(self, batch, min_age): + config = SMSProviderConfig.load() + if config.provider != "sms0098ws": + # smsid تنها متعلق به این provider است؛ استعلام با بقیه بی‌معناست. + logger.info("provider فعلی رهگیری دلیوری ندارد؛ استعلام رد شد.") + return 0 + + now = timezone.now() + cutoff = now - timedelta(seconds=max(0, min_age)) + # پیامک‌های ارسال‌شده که شناسه دارند و وضعیت‌شان هنوز نهایی نشده است. + query = SMSQueue.objects.filter( + Q(status=SMSQueue.Status.SENT, provider_id__gt="", sent_at__lte=cutoff), + Q(delivery_checked_at__isnull=True) + | Q(delivery_status__in=["", SMSQueue.DeliveryStatus.PENDING, + SMSQueue.DeliveryStatus.UNKNOWN]), + ) + + try: + provider = get_sms_provider(config) + except Exception: + logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.") + return 0 + + processed = 0 + for row in query.order_by("sent_at")[:batch]: + if self._stop: + break + result = provider.get_delivery_status(row.provider_id) + if result is None: + # خطای ارتباط/تنظیمات: ردیف را رد کن تا دور بعد دوباره تلاش شود. + continue + row.delivery_status = result.status + row.delivery_checked_at = timezone.now() + row.save(update_fields=["delivery_status", "delivery_checked_at", "updated_at"]) + processed += 1 + self._emit( + f" دلیوری #{row.pk} ({row.provider_id}): {result.status} " + f"{'(نهایی)' if result.is_final else '(در انتظار)'}", + ) + return processed diff --git a/admin_panel/management/commands/sms_scan.py b/admin_panel/management/commands/sms_scan.py new file mode 100644 index 0000000..551e71e --- /dev/null +++ b/admin_panel/management/commands/sms_scan.py @@ -0,0 +1,50 @@ +"""زمان‌بند/اسکنر: پیدا کردن موارد سررسیدشده و قرار دادن آن‌ها در صف پیامک.""" +import logging +import signal +import time + +from django.core.management.base import BaseCommand + +from admin_panel.sms.scanner import run_all + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "اسکن یادآوری‌های پیامک و قرار دادن آن‌ها در صف." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._stop = False + + def add_arguments(self, parser): + parser.add_argument("--loop", action="store_true", help="اجرای پیوسته") + parser.add_argument("--interval", type=float, default=300.0, help="فاصلهٔ بین اجراها (ثانیه)") + + def handle(self, *args, **options): + signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) + loop = options["loop"] + interval = options["interval"] + + while not self._stop: + results = run_all() + summary = ", ".join(f"{name}={count}" for name, count in results.items()) + self._emit(f"اسکن پیامک: {summary}") + if not loop: + break + # خواب قابل‌قطع + slept = 0.0 + while slept < interval and not self._stop: + time.sleep(min(1.0, interval - slept)) + slept += 1.0 + + def _handle_signal(self, signum, frame): + self._stop = True + + def _emit(self, text): + """نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز).""" + try: + self.stdout.write(text) + except UnicodeEncodeError: + self.stdout.write(text.encode("ascii", "replace").decode("ascii")) diff --git a/admin_panel/management/commands/sms_worker.py b/admin_panel/management/commands/sms_worker.py new file mode 100644 index 0000000..83003fb --- /dev/null +++ b/admin_panel/management/commands/sms_worker.py @@ -0,0 +1,179 @@ +"""worker تخلیه‌کنندهٔ صف پیامک. + +در یک پردازش جدا اجرا می‌شود تا کندی/خطای سامانه پیامک به برنامه اصلی ضربه نزند. +""" +import logging +import signal +import time +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.utils import timezone + +from admin_panel.models import SMSLog, SMSProviderConfig, SMSQueue +from integrations.sms.factory import get_sms_provider + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "تخلیهٔ صف پیامک و ارسال از طریق سرویس‌دهندهٔ تنظیم‌شده." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._stop = False + + def add_arguments(self, parser): + parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج") + parser.add_argument("--sleep", type=float, default=3.0, help="فاصلهٔ بین دورها (ثانیه)") + parser.add_argument( + "--batch", type=int, default=None, + help="تعداد پیامک در هر دور (پیش‌فرض: max_per_run از تنظیمات پنل)", + ) + + def handle(self, *args, **options): + signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) + once = options["once"] + sleep_seconds = options["sleep"] + batch = options["batch"] + + self._emit("worker پیامک آغاز شد.", style=self.style.SUCCESS) + while not self._stop: + # یک دور خراب (خطای دیتابیس، provider، …) نباید کل worker را از پا دربیاورد؛ + # در غیر این‌صورت صف تا ری‌استارت دستی راکد می‌ماند. + try: + processed = self._drain_batch(batch) + except Exception: + logger.exception("خطای غیرمنتظره در یک دور از worker پیامک؛ دور بعد ادامه می‌دهد.") + processed = 0 + if once: + break + if processed == 0: + self._sleep_interruptible(sleep_seconds) + self._emit("worker پیامک متوقف شد.", style=self.style.SUCCESS) + + def _handle_signal(self, signum, frame): + self._stop = True + + def _emit(self, text, *, style=None, err=False): + """نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز). + + چاپ، عملیات جانبی است: اگر کنسول نتواند متن را بنویسد نباید ارسال‌ها را + متوقف یا «ناموفق» کند. اول متن کامل، بعد نسخهٔ ASCII-شده، و در نهایت سکوت. + """ + stream = self.stderr if err else self.stdout + style = style or (lambda value: value) + for payload in (text, text.encode("ascii", "replace").decode("ascii")): + try: + stream.write(style(payload)) + return + except (UnicodeEncodeError, ValueError, OSError): + continue + + def _drain_batch(self, batch): + config = SMSProviderConfig.load() + if not config.is_enabled: + logger.info("ارسال پیامک از تنظیمات غیرفعال است؛ صف دست‌نخورده می‌ماند.") + return 0 + + if not batch: + batch = config.max_per_run or 20 + interval = config.send_interval_seconds or 0 + + now = timezone.now() + # تنها پیامک‌هایی که مدیر صراحتاً تأیید کرده ارسال می‌شوند؛ موارد «در + # انتظار تأیید» دست‌نخورده در صف بازبینی می‌مانند. + candidates = SMSQueue.objects.filter( + status=SMSQueue.Status.APPROVED, + ).filter( + Q(next_retry_at__isnull=True) | Q(next_retry_at__lte=now), + ).order_by("created_at")[:batch] + + try: + provider = get_sms_provider(config) + except Exception: + logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.") + return 0 + + processed = 0 + for item in candidates: + if self._stop: + break + # claim اتمیک مثبت‌گرا (SQLite از select_for_update پشتیبانی نمی‌کند). + claimed = SMSQueue.objects.filter( + pk=item.pk, status=SMSQueue.Status.APPROVED, + ).update(status=SMSQueue.Status.PROCESSING) + if not claimed: + continue + try: + row = SMSQueue.objects.get(pk=item.pk) + except SMSQueue.DoesNotExist: + # رکورد بین انتخاب و claim حذف شده (مثلاً پاک‌سازی دستی پنل). + continue + # فاصلهٔ بین پیامک‌ها: پیش از ارسال مورد بعدی صبر می‌کنیم (نه بعد از آخرین). + if processed and interval: + self._sleep_interruptible(interval) + if self._stop: + # مورد claim‌شده را به صف برگردان تا از دست نرود. + SMSQueue.objects.filter( + pk=row.pk, status=SMSQueue.Status.PROCESSING, + ).update(status=SMSQueue.Status.APPROVED) + break + processed += 1 + self._send(provider, row) + return processed + + def _sleep_interruptible(self, seconds): + """خواب قابل‌قطع تا سیگنال توقف سریع پاسخ داده شود.""" + remaining = float(seconds) + while remaining > 0 and not self._stop: + step = min(0.5, remaining) + time.sleep(step) + remaining -= step + + def _send(self, provider, row): + try: + result = provider.send_message(row.phone_number, row.message) + except Exception as exc: # provider نباید worker را از پا دربیاورد + logger.exception("استثنای غیرمنتظره در ارسال پیامک #%s", row.pk) + result = None + error = str(exc) + else: + error = getattr(result, "error", "") or "" + result = result if getattr(result, "success", False) else None + + now = timezone.now() + row.attempts = (row.attempts or 0) + 1 + if result is not None: + row.status = SMSQueue.Status.SENT + row.sent_at = now + row.provider_id = getattr(result, "provider_id", "") or "" + row.provider_code = getattr(result, "provider_code", "") or "" + # شناسه (smsid) داریم ⇒ اهل رهگیری دلیوری؛ دستور sms_delivery پر می‌کند. + row.delivery_status = ( + SMSQueue.DeliveryStatus.PENDING if row.provider_id else "" + ) + row.last_error = "" + row.save() + SMSLog.objects.create( + queue=row, attempt_no=row.attempts, success=True, + provider_id=row.provider_id, provider_code=row.provider_code, + ) + self._emit(f" ارسال موفق #{row.pk} -> {row.phone_number}") + return + + row.last_error = error[:2000] + if row.attempts >= (row.max_attempts or 5): + row.status = SMSQueue.Status.FAILED + else: + # همچنان تأییدشده می‌ماند تا تلاش خودکار بعدی ادامه یابد. + row.status = SMSQueue.Status.APPROVED + backoff = min(3600, 30 * (2 ** row.attempts)) + row.next_retry_at = now + timedelta(seconds=backoff) + row.save() + SMSLog.objects.create( + queue=row, attempt_no=row.attempts, success=False, error=row.last_error, + ) + self._emit(f" ارسال ناموفق #{row.pk} (تلاش {row.attempts}): {row.last_error[:120]}", err=True) diff --git a/admin_panel/migrations/0057_sms.py b/admin_panel/migrations/0057_sms.py new file mode 100644 index 0000000..4a3e0df --- /dev/null +++ b/admin_panel/migrations/0057_sms.py @@ -0,0 +1,124 @@ +# Generated by Django 3.2.25 on 2026-10-09 16:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('admin_panel', '0056_auto_20260731_1824'), + ] + + operations = [ + migrations.CreateModel( + name='SMSProviderConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('provider', models.CharField(choices=[('sms0098', '0098 (SOAP/zeep)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک')), + ('is_enabled', models.BooleanField(default=True, help_text='در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی\u200cکند.', verbose_name='فعال بودن ارسال پیامک')), + ('api_url', models.CharField(blank=True, default='', max_length=500, verbose_name='آدرس سرویس (WSDL یا URL)')), + ('username', models.CharField(blank=True, default='', max_length=100, verbose_name='نام کاربری')), + ('password', models.CharField(blank=True, default='', max_length=200, verbose_name='رمز عبور')), + ('sender', models.CharField(blank=True, default='', max_length=50, verbose_name='شماره خط فرستنده')), + ('api_token', models.CharField(blank=True, default='', max_length=255, verbose_name='توکن API (HTTP)')), + ('timeout_seconds', models.PositiveIntegerField(default=30, verbose_name='مهلت ارتباط (ثانیه)')), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'تنظیمات سامانه پیامک', + 'verbose_name_plural': 'تنظیمات سامانه پیامک', + 'permissions': [('ManageSMS', 'مدیریت پیامک')], + }, + ), + migrations.CreateModel( + name='SMSTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, unique=True, verbose_name='نوع پیامک')), + ('title', models.CharField(max_length=100, verbose_name='عنوان')), + ('text', models.TextField(help_text='متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، {amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، {check_row} شماره ردیف چک، {owner_name} نام مالک چک.', verbose_name='متن پیامک')), + ('is_enabled', models.BooleanField(default=True, verbose_name='فعال')), + ('offset_days', models.PositiveSmallIntegerField(default=2, verbose_name='چند روز قبل از سررسید ارسال شود')), + ('repeat_interval_days', models.PositiveSmallIntegerField(blank=True, help_text='خالی بگذارید برای ارسال یک\u200cباره؛ مقدار بدهید برای ارسال تکرارشونده.', null=True, verbose_name='فاصله تکرار (روز)')), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'قالب پیامک', + 'verbose_name_plural': 'قالب\u200cهای پیامک', + }, + ), + migrations.AddField( + model_name='phonenumber', + name='is_primary', + field=models.BooleanField(default=False, verbose_name='شماره اصلی (گیرنده پیامک)'), + ), + migrations.CreateModel( + name='SMSQueue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sms_type', models.CharField(blank=True, choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], default='', max_length=40, verbose_name='نوع')), + ('phone_number', models.CharField(max_length=20, verbose_name='شماره گیرنده')), + ('message', models.TextField(verbose_name='متن')), + ('status', models.CharField(choices=[('pending', 'در انتظار'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت')), + ('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='تعداد تلاش')), + ('max_attempts', models.PositiveSmallIntegerField(default=5, verbose_name='حداکثر تلاش')), + ('next_retry_at', models.DateTimeField(blank=True, null=True, verbose_name='زمان تلاش بعدی')), + ('last_error', models.TextField(blank=True, default='', verbose_name='آخرین خطا')), + ('provider_id', models.CharField(blank=True, default='', max_length=100)), + ('provider_code', models.CharField(blank=True, default='', max_length=50)), + ('dedup_key', models.CharField(blank=True, help_text='کلید یکتاسازی برای جلوگیری از ارسال تکراری.', max_length=120, null=True, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sms_queue', to='admin_panel.customer', verbose_name='مشتری')), + ], + options={ + 'verbose_name': 'صف پیامک', + 'verbose_name_plural': 'صف پیامک\u200cها', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SMSLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attempt_no', models.PositiveSmallIntegerField(default=1, verbose_name='شماره تلاش')), + ('success', models.BooleanField(default=False, verbose_name='موفق')), + ('provider_id', models.CharField(blank=True, default='', max_length=100)), + ('provider_code', models.CharField(blank=True, default='', max_length=50)), + ('error', models.TextField(blank=True, default='', verbose_name='خطا')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('queue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='admin_panel.smsqueue', verbose_name='صف')), + ], + options={ + 'verbose_name': 'لاگ پیامک', + 'verbose_name_plural': 'لاگ\u200cهای پیامک', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CustomerSMSSetting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, verbose_name='نوع پیامک')), + ('is_enabled', models.BooleanField(default=True, verbose_name='فعال')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sms_settings', to='admin_panel.customer')), + ], + options={ + 'verbose_name': 'تنظیم پیامک مشتری', + 'verbose_name_plural': 'تنظیمات پیامک مشتریان', + }, + ), + migrations.AddIndex( + model_name='smsqueue', + index=models.Index(fields=['status', 'next_retry_at'], name='admin_panel_status_c227cc_idx'), + ), + migrations.AlterUniqueTogether( + name='customersmssetting', + unique_together={('customer', 'sms_type')}, + ), + ] diff --git a/admin_panel/migrations/0058_seed_sms_templates.py b/admin_panel/migrations/0058_seed_sms_templates.py new file mode 100644 index 0000000..59a540a --- /dev/null +++ b/admin_panel/migrations/0058_seed_sms_templates.py @@ -0,0 +1,115 @@ +"""دادهٔ اولیه: متن پیش‌فرض قالب‌های پیامک + علامت‌گذاری شمارهٔ اصلی از فیلدهای قدیمی. + +این مایگریشن idempotent است (get_or_create / update_if) و اجرای دوبارهٔ آن بی‌خطر است. +""" +from django.db import migrations + +DEFAULT_TEMPLATES = [ + { + "sms_type": "welcome", + "title": "خوش‌آمدگویی", + "offset_days": 0, + "repeat_interval_days": None, + "text": "{name} عزیز، از خرید شما سپاسگزاریم. به‌امید دیدار دوباره.", + }, + { + "sms_type": "cheque_due", + "title": "یادآوری سررسید چک", + "offset_days": 2, + "repeat_interval_days": None, + "text": "{name} عزیز، {days} روز تا سررسید چک شما به مبلغ {amount_sep} ریال باقی مانده است. لطفاً نسبت به تأمین موجودی اقدام فرمایید.", + }, + { + "sms_type": "nesiye_due", + "title": "یادآوری سررسید نسیه", + "offset_days": 2, + "repeat_interval_days": None, + "text": "{name} عزیز، {days} روز تا سررسید بدهی شما به مبلغ {amount_sep} ریال باقی مانده است.", + }, + { + "sms_type": "cheque_registration", + "title": "یادآوری تأمین موجودی چک", + "offset_days": 2, + "repeat_interval_days": 3, + "text": "{name} عزیز، سررسید چک شما ({check_row}) نزدیک است. لطفاً موجودی بانکی حساب خود را تأمین فرمایید.", + }, + { + "sms_type": "debt_due", + "title": "یادآوری بدهی", + "offset_days": 0, + "repeat_interval_days": None, + "text": "{name} عزیز، مبلغ {amount_sep} ریال بدهی شما باقی مانده است. لطفاً نسبت به تسویه اقدام فرمایید.", + }, + { + "sms_type": "visit", + "title": "یادآوری بازدید خدمات", + "offset_days": 0, + "repeat_interval_days": None, + "text": "{name} عزیز، زمان بازدید دوره‌ای دستگاه شما فرا رسیده است. برای تعیین وقت با ما تماس بگیرید.", + }, + { + "sms_type": "survey", + "title": "نظرسنجی", + "offset_days": 2, + "repeat_interval_days": None, + "text": "{name} عزیز، نظر شما دربارهٔ خدمات ما برایمان ارزشمند است. لطفاً در نظرسنجی شرکت فرمایید.", + }, + { + "sms_type": "custom", + "title": "پیام سفارشی", + "offset_days": 0, + "repeat_interval_days": None, + "text": "", + }, +] + + +def seed_templates(apps, schema_editor): + SMSTemplate = apps.get_model("admin_panel", "SMSTemplate") + for item in DEFAULT_TEMPLATES: + SMSTemplate.objects.get_or_create( + sms_type=item["sms_type"], + defaults={ + "title": item["title"], + "text": item["text"], + "is_enabled": True, + "offset_days": item["offset_days"], + "repeat_interval_days": item["repeat_interval_days"], + }, + ) + + +def unseed_templates(apps, schema_editor): + SMSTemplate = apps.get_model("admin_panel", "SMSTemplate") + SMSTemplate.objects.filter( + sms_type__in=[t["sms_type"] for t in DEFAULT_TEMPLATES] + ).delete() + + +def backfill_primary_phones(apps, schema_editor): + """اگر هیچ شمارهٔ اصلی‌ای ثبت نشده، شمارهٔ مطابق phone1 هر مشتری را اصلی می‌کند.""" + Customer = apps.get_model("admin_panel", "Customer") + PhoneNumber = apps.get_model("admin_panel", "PhoneNumber") + for customer in Customer.objects.all(): + if PhoneNumber.objects.filter(customer=customer, is_primary=True).exists(): + continue + phone1 = (customer.phone1 or "").strip() + if not phone1: + continue + PhoneNumber.objects.filter(customer=customer, phone_number=phone1).update(is_primary=True) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("admin_panel", "0057_sms"), + ] + + operations = [ + migrations.RunPython(seed_templates, unseed_templates), + migrations.RunPython(backfill_primary_phones, noop), + ] diff --git a/admin_panel/migrations/0059_sms_flow_control.py b/admin_panel/migrations/0059_sms_flow_control.py new file mode 100644 index 0000000..8dfb13e --- /dev/null +++ b/admin_panel/migrations/0059_sms_flow_control.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.25 on 2026-10-09 17:08 + +from django.db import migrations, models + + +def stamp_scan_since(apps, schema_editor): + """روی نصب‌های موجود، خط شروع را روی «همین حالا» مهر می‌کند. + + نتیجه: پس از به‌روزرسانی، موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نمی‌گیرند. + """ + from django.utils import timezone + SMSProviderConfig = apps.get_model('admin_panel', 'SMSProviderConfig') + SMSProviderConfig.objects.filter(scan_since__isnull=True).update(scan_since=timezone.now()) + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0058_seed_sms_templates'), + ] + + operations = [ + migrations.AddField( + model_name='smsproviderconfig', + name='allow_backlog', + field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «خط شروع» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است.', verbose_name='اجازهٔ ارسال موارد قدیمی'), + ), + migrations.AddField( + model_name='smsproviderconfig', + name='backlog_max', + field=models.PositiveIntegerField(default=0, help_text='فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.', verbose_name='حداکثر موارد قدیمی در هر اجرا'), + ), + migrations.AddField( + model_name='smsproviderconfig', + name='max_per_run', + field=models.PositiveIntegerField(default=200, help_text='سقف کلی صف\u200cکردن برای هر نوع پیامک در هر اجرای اسکنر.', verbose_name='حداکثر پیامک هر اسکنر در هر اجرا'), + ), + migrations.AddField( + model_name='smsproviderconfig', + name='scan_since', + field=models.DateTimeField(blank=True, help_text='فقط مواردی که پنجرهٔ یادآوری\u200cشان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (قبل از این لحظه) نادیده گرفته می\u200cشوند. اگر خالی باشد، در نخستین اجرا به\u200cصورت خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='خط شروع ارسال'), + ), + migrations.AddField( + model_name='smsproviderconfig', + name='send_interval_seconds', + field=models.PositiveIntegerField(default=3, help_text='worker پس از هر ارسال این مدت صبر می\u200cکند تا سرویس\u200cدهنده زیر بار نرود.', verbose_name='فاصلهٔ بین هر پیامک (ثانیه)'), + ), + migrations.RunPython(stamp_scan_since, migrations.RunPython.noop), + ] diff --git a/admin_panel/migrations/0060_sms_permission_label.py b/admin_panel/migrations/0060_sms_permission_label.py new file mode 100644 index 0000000..bdda872 --- /dev/null +++ b/admin_panel/migrations/0060_sms_permission_label.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.25 on 2026-10-09 17:15 + +from django.db import migrations + + +def rename_manage_sms_permission(apps, schema_editor): + """نام نمایشی پرمیشن را با قرارداد پروژه هم‌راستا می‌کند. + + Django هنگام ``AlterModelOptions`` رکورد Permission موجود را تغییر نمی‌دهد؛ + پس نام قبلی (فارسی) روی نصب‌های موجود باقی می‌ماند و باید صریحاً به‌روزرسانی شود. + """ + Permission = apps.get_model('auth', 'Permission') + Permission.objects.filter( + content_type__app_label='admin_panel', + content_type__model='smsproviderconfig', + codename='ManageSMS', + ).update(name='ManageSMS') + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0059_sms_flow_control'), + ] + + operations = [ + migrations.AlterModelOptions( + name='smsproviderconfig', + options={'permissions': [('ManageSMS', 'ManageSMS')], 'verbose_name': 'تنظیمات سامانه پیامک', 'verbose_name_plural': 'تنظیمات سامانه پیامک'}, + ), + migrations.RunPython(rename_manage_sms_permission, migrations.RunPython.noop), + ] diff --git a/admin_panel/migrations/0061_sms_timing_and_primary.py b/admin_panel/migrations/0061_sms_timing_and_primary.py new file mode 100644 index 0000000..667ceb7 --- /dev/null +++ b/admin_panel/migrations/0061_sms_timing_and_primary.py @@ -0,0 +1,119 @@ +"""یکسان‌سازی شماره‌های اصلی + زمان‌بندی اختصاصی هر نوع پیامک. + +* «شمارهٔ اصلی» از رکورد ``PhoneNumber`` به خودِ ``Customer`` منتقل می‌شود و با مقدارِ + نرمال‌شدهٔ شماره ذخیره می‌گردد تا با افزودن/حذف/ویرایش مداوم مشتریان پایدار بماند. +* فیلد زمانی هر قالب با معنای واقعی همان نوع هم‌راستا می‌شود (مثلاً نظرسنجی = ۳ روز بعد + از صدور فاکتور، و یادآوری ثبت چک = هر ۳ روز تا ثبت اطلاعات چک). +""" +from django.db import migrations, models + + +def _normalize(raw): + """نرمال‌سازی شماره (بدون وابستگی به کد اپ، برای اجرای امن روی دادهٔ قدیمی).""" + from integrations.sms.normalize import normalize_mobile + + return normalize_mobile(raw) + + +def backfill_primary_phones(apps, schema_editor): + """پرچم‌های ``is_primary`` موجود را به مقدار نرمال‌شده روی مشتری منتقل می‌کند.""" + PhoneNumber = apps.get_model("admin_panel", "PhoneNumber") + for customer_id, phone_number in ( + PhoneNumber.objects.filter(is_primary=True) + .values_list("customer_id", "phone_number") + ): + normalized = _normalize((phone_number or "").strip()) + if not normalized: + continue + customer = apps.get_model("admin_panel", "Customer").objects.filter(pk=customer_id).first() + if customer is None: + continue + current = [p for p in (customer.sms_primary_phones or "").split(",") if p] + if normalized in current: + continue + current.append(normalized) + customer.sms_primary_phones = ",".join(current) + customer.save(update_fields=["sms_primary_phones"]) + + +def normalize_template_timing(apps, schema_editor): + """مقادیر زمانیِ بی‌معنی را با معنای درست هر نوع پیامک هم‌راستا می‌کند. + + فقط مقادیری که برای آن نوع معنا ندارند اصلاح می‌شوند؛ تنظیمات معنادارِ مدیر دست‌نخورده + می‌ماند. + """ + SMSTemplate = apps.get_model("admin_panel", "SMSTemplate") + + # نظرسنجی: «۳ روز بعد از صدور فاکتور» (مقدار پیشین ۲ بود). + SMSTemplate.objects.filter(sms_type="survey", offset_days=2).update(offset_days=3) + + # یادآوری ثبت چک: فاصله تکرار ۳ روز و بدون offset سررسید. + for template in SMSTemplate.objects.filter(sms_type="cheque_registration"): + template.offset_days = 0 + if not template.repeat_interval_days: + template.repeat_interval_days = 3 + template.save(update_fields=["offset_days", "repeat_interval_days"]) + + # یادآوری بازدید: دورهٔ پیش‌فرض ۹ ماه (قابل تغییر از پنل). + SMSTemplate.objects.filter( + sms_type="visit", repeat_interval_months__isnull=True, + ).update(repeat_interval_months=9) + + # متن «یادآوری ثبت چک» به معنای جدید (ثبت اطلاعات چک) هم‌راستا می‌شود. + SMSTemplate.objects.filter(sms_type="cheque_registration").update( + title="یادآوری ثبت اطلاعات چک", + text="{name} عزیز، برای چک با سررسید {date} هنوز اطلاعات ثبت چک " + "(شمارهٔ صیادی) در سامانه ثبت نشده است. لطفاً نسبت به ثبت آن اقدام فرمایید.", + ) + + # سایر انواع: هیچ فیلد زمانی نباید مقدار داشته باشد. + SMSTemplate.objects.filter( + sms_type__in=["welcome", "debt_due", "custom"], + ).update(offset_days=0, repeat_interval_days=None, repeat_interval_months=None) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("admin_panel", "0060_sms_permission_label"), + ] + + operations = [ + migrations.AddField( + model_name="customer", + name="sms_primary_phones", + field=models.CharField( + blank=True, default="", max_length=100, + verbose_name="شماره‌های اصلی پیامک", + help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.", + ), + ), + # ترتیب مهم است: ابتدا مقادیر فعلی خوانده و منتقل می‌شوند، سپس ستون قدیمی حذف می‌گردد. + migrations.RunPython(backfill_primary_phones, noop), + migrations.RemoveField(model_name="phonenumber", name="is_primary"), + migrations.AddField( + model_name="smstemplate", + name="repeat_interval_months", + field=models.PositiveSmallIntegerField( + blank=True, null=True, + verbose_name="دورهٔ تکرار (ماه)", + help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. " + "خالی = پیش‌فرض سامانه (۹ ماه).", + ), + ), + migrations.AlterField( + model_name="smstemplate", + name="offset_days", + field=models.PositiveSmallIntegerField( + default=0, + verbose_name="فاصلهٔ زمانی (روز)", + help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده " + "می‌شود؛ مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».", + ), + ), + migrations.RunPython(normalize_template_timing, noop), + ] diff --git a/admin_panel/migrations/0062_sms_link_provider.py b/admin_panel/migrations/0062_sms_link_provider.py new file mode 100644 index 0000000..51e9695 --- /dev/null +++ b/admin_panel/migrations/0062_sms_link_provider.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2026-10-09 18:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0061_sms_timing_and_primary'), + ] + + operations = [ + migrations.AlterField( + model_name='smsproviderconfig', + name='api_url', + field=models.CharField(blank=True, default='http://www.0098sms.com/sendsmslink.aspx', help_text='برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.', max_length=500, verbose_name='آدرس سرویس پیامک (URL)'), + ), + migrations.AlterField( + model_name='smsproviderconfig', + name='provider', + field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'), + ), + ] diff --git a/admin_panel/migrations/0063_alter_smsqueue_status.py b/admin_panel/migrations/0063_alter_smsqueue_status.py new file mode 100644 index 0000000..7302112 --- /dev/null +++ b/admin_panel/migrations/0063_alter_smsqueue_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-10-10 06:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0062_sms_link_provider'), + ] + + operations = [ + migrations.AlterField( + model_name='smsqueue', + name='status', + field=models.CharField(choices=[('pending', 'در انتظار تأیید'), ('approved', 'تأییدشده'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت'), + ), + ] diff --git a/admin_panel/migrations/0064_sms_webservice_delivery.py b/admin_panel/migrations/0064_sms_webservice_delivery.py new file mode 100644 index 0000000..5a9ba99 --- /dev/null +++ b/admin_panel/migrations/0064_sms_webservice_delivery.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.25 on 2026-10-10 07:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0063_alter_smsqueue_status'), + ] + + operations = [ + migrations.AddField( + model_name='smsproviderconfig', + name='panel_no', + field=models.CharField(blank=True, default='', help_text='شمارهٔ اختصاصی پنل در وب\u200cسرویس؛ اگر خالی بماند از «شماره خط فرستنده» استفاده می\u200cشود.', max_length=50, verbose_name='شماره پنل (PnlNo)'), + ), + migrations.AddField( + model_name='smsproviderconfig', + name='webservice_url', + field=models.CharField(blank=True, default='https://webservice.0098sms.com/service.asmx', help_text='فقط برای provider «وب\u200cسرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام دلیوری از همین آدرس انجام می\u200cشود.', max_length=500, verbose_name='آدرس وب\u200cسرویس (SOAP/WSDL)'), + ), + migrations.AddField( + model_name='smsqueue', + name='delivery_checked_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='زمان آخرین استعلام دلیوری'), + ), + migrations.AddField( + model_name='smsqueue', + name='delivery_status', + field=models.CharField(blank=True, choices=[('pending', 'در انتظار استعلام'), ('delivered', 'رسیده به گوشی'), ('not_delivered', 'نرسیده به گوشی'), ('expired', 'منقضی\u200cشده'), ('unknown', 'نامشخص')], db_index=True, default='', help_text='خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می\u200cشود.', max_length=16, verbose_name='وضعیت تحویل'), + ), + migrations.AlterField( + model_name='smsproviderconfig', + name='provider', + field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('sms0098ws', '0098SMS (وب\u200cسرویس + استعلام دلیوری)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'), + ), + ] diff --git a/admin_panel/migrations/0065_smsproviderconfig_test_message.py b/admin_panel/migrations/0065_smsproviderconfig_test_message.py new file mode 100644 index 0000000..715e94b --- /dev/null +++ b/admin_panel/migrations/0065_smsproviderconfig_test_message.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.25 on 2026-10-10 07:48 + +from django.db import migrations, models + + +def scrub_brand(apps, schema_editor): + """حذف نام برند از متن قالب‌هایی که پیش‌تر با «کتیبه» ذخیره شده‌اند.""" + SMSTemplate = apps.get_model("admin_panel", "SMSTemplate") + for template in SMSTemplate.objects.filter(text__contains="کتیبه"): + cleaned = ( + template.text.replace("به خانواده کتیبه خوش آمدید. ", "") + .replace(" نزد کتیبه", "") + ) + template.text = cleaned + template.save(update_fields=["text"]) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0064_sms_webservice_delivery'), + ] + + operations = [ + migrations.AddField( + model_name='smsproviderconfig', + name='test_message', + field=models.TextField(blank=True, default='پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.', help_text='متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.', verbose_name='متن پیامک آزمایشی'), + ), + migrations.RunPython(scrub_brand, noop), + ] diff --git a/admin_panel/migrations/0066_smsdedup.py b/admin_panel/migrations/0066_smsdedup.py new file mode 100644 index 0000000..d6b3bc2 --- /dev/null +++ b/admin_panel/migrations/0066_smsdedup.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.25 on 2026-10-10 08:05 + +from django.db import migrations, models + + +def backfill_keys(apps, schema_editor): + """کلیدهای صفِ موجود را به دفتر منتقل می‌کند تا حذف بعدی‌شان باعث بازگشت نشود.""" + SMSQueue = apps.get_model("admin_panel", "SMSQueue") + SMSDedup = apps.get_model("admin_panel", "SMSDedup") + keys = SMSQueue.objects.exclude(dedup_key__isnull=True).exclude( + dedup_key="" + ).values_list("dedup_key", flat=True) + SMSDedup.objects.bulk_create( + [SMSDedup(key=key) for key in keys], ignore_conflicts=True, + ) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0065_smsproviderconfig_test_message'), + ] + + operations = [ + migrations.CreateModel( + name='SMSDedup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=120, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'کلید پردازش\u200cشدهٔ پیامک', + 'verbose_name_plural': 'کلیدهای پردازش\u200cشدهٔ پیامک', + }, + ), + migrations.RunPython(backfill_keys, noop), + ] diff --git a/admin_panel/migrations/0067_customer_notes.py b/admin_panel/migrations/0067_customer_notes.py new file mode 100644 index 0000000..91ad76c --- /dev/null +++ b/admin_panel/migrations/0067_customer_notes.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.25 on 2026-10-10 08:43 + +from django.db import migrations, models + + +def scrub_legacy_phones(apps, schema_editor): + """پاک‌سازی یک‌بارهٔ فیلدهای تلفن قدیمی (idempotent). + + همان قاعدهٔ ``admin_panel.sms.phone_cleanup``؛ اینجا فقط مدل از رجیستری + تاریخی گرفته می‌شود. اجرای دوباره بی‌اثر است. + """ + from admin_panel.sms.phone_cleanup import clean_all + + clean_all(apps=apps) + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0066_smsdedup'), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='notes', + field=models.TextField(blank=True, default='', help_text='یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.', verbose_name='توضیحات'), + ), + migrations.RunPython(scrub_legacy_phones, migrations.RunPython.noop), + ] diff --git a/admin_panel/migrations/0068_auto_20261010_1305.py b/admin_panel/migrations/0068_auto_20261010_1305.py new file mode 100644 index 0000000..09c2fe8 --- /dev/null +++ b/admin_panel/migrations/0068_auto_20261010_1305.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2026-10-10 09:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0067_customer_notes'), + ] + + operations = [ + migrations.AlterField( + model_name='smsproviderconfig', + name='allow_backlog', + field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است تا مشتریان قدیمی مشمول این فیلتر شوند.', verbose_name='اجازهٔ ارسال موارد قدیمی'), + ), + migrations.AlterField( + model_name='smsproviderconfig', + name='scan_since', + field=models.DateTimeField(blank=True, help_text='فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (مثل مشتریان خیلی قدیمی که مدت\u200cهاست بازدید نشده\u200cاند) نادیده گرفته می\u200cشوند. از پنل به شکل تاریخ شمسی ویرایش می\u200cشود و روی «آغاز همان روز» تنظیم می\u200cگردد. اگر خالی باشد، در نخستین اجرا خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='تاریخ شروع دوره'), + ), + ] diff --git a/admin_panel/migrations/0069_smstemplate_auto_send.py b/admin_panel/migrations/0069_smstemplate_auto_send.py new file mode 100644 index 0000000..6fb7a5d --- /dev/null +++ b/admin_panel/migrations/0069_smstemplate_auto_send.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-10-10 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0068_auto_20261010_1305'), + ] + + operations = [ + migrations.AddField( + model_name='smstemplate', + name='auto_send', + field=models.BooleanField(default=False, help_text='اگر خاموش باشد (پیش\u200cفرض)، پیامک\u200cهای این نوع در وضعیت «در انتظار تأیید» در صف می\u200cمانند تا مدیر آن\u200cها را تأیید کند. با روشن\u200cکردن، پیامکِ ساخته\u200cشده توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می\u200cشود.', verbose_name='ارسال خودکار (بدون تأیید مدیر)'), + ), + ] diff --git a/admin_panel/models/__init__.py b/admin_panel/models/__init__.py index 23ab797..4e3d485 100644 --- a/admin_panel/models/__init__.py +++ b/admin_panel/models/__init__.py @@ -17,3 +17,4 @@ from .factors import * # noqa: F401,F403 from .finance import * # noqa: F401,F403 from .actions import * # noqa: F401,F403 from .system import * # noqa: F401,F403 +from .sms import * # noqa: F401,F403 diff --git a/admin_panel/models/customers.py b/admin_panel/models/customers.py index 7cbe025..bcc8bfa 100644 --- a/admin_panel/models/customers.py +++ b/admin_panel/models/customers.py @@ -11,6 +11,12 @@ class Customer(models.Model): ] name_family = models.CharField(max_length=200) address = models.CharField(max_length=200) + # توضیحات آزاد مشتری؛ متن‌های غیرشماره‌ای که از فیلدهای تلفن پاک‌سازی می‌شوند + # اینجا نگه داشته می‌شوند تا هیچ داده‌ای از سیستم حذف نشود. + notes = models.TextField( + blank=True, default="", verbose_name="توضیحات", + help_text="یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.", + ) phone1 = models.CharField(max_length=20, default='') phone2 = models.CharField(max_length=20, default='', null=True, blank=True) phone3 = models.CharField(max_length=20, default='', null=True, blank=True) @@ -23,6 +29,16 @@ class Customer(models.Model): # فیلد جدید برای حذف نرم (بدون تداخل با داده‌های قبلی) is_active = models.BooleanField(default=True, null=True, blank=True, help_text="وضعیت فعالیت مشتری") + # شماره‌های «اصلی» گیرندهٔ پیامک، به‌صورت مقدارِ خودِ شماره (نرمال‌شده، جدا با کاما). + # چرا مقدار و نه کلیدِ رکورد؟ چون دادهٔ مشتریان دائماً افزوده/ویرایش/حذف می‌شود و + # کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره پایدار است. اگر شماره‌ای حذف یا + # ویرایش شود، این انتخاب خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول برمی‌گردد. + sms_primary_phones = models.CharField( + max_length=100, blank=True, default="", + verbose_name="شماره‌های اصلی پیامک", + help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.", + ) + def __str__(self): return self.name_family @@ -37,6 +53,8 @@ class PhoneNumber(models.Model): customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='phones') title = models.CharField(max_length=50, default='تلفن', blank=True, null=True, help_text="عنوان شماره (مثل: موبایل پدر)") phone_number = models.CharField(max_length=20) + # توجه: «شمارهٔ اصلی پیامک» روی خودِ Customer.sms_primary_phones نگه داشته می‌شود + # (کلیدش مقدار شماره است، نه رکورد) تا با افزودن/حذف/ویرایش مداوم مشتریان از بین نرود. def __str__(self): return f"{self.title}: {self.phone_number}" diff --git a/admin_panel/models/sms.py b/admin_panel/models/sms.py new file mode 100644 index 0000000..857ce1f --- /dev/null +++ b/admin_panel/models/sms.py @@ -0,0 +1,314 @@ +"""مدل‌های زیرسیستم پیامک. + +شامل: +- تنظیمات سامانه پیامک (singleton) با URL/نام‌کاربری/رمز قابل ویرایش از Django Admin. +- قالب متن هر نوع پیامک (قابل ویرایش از پنل). +- تنظیم فعال/غیرفعال هر نوع پیامک به‌ازای هر مشتری. +- صف ارسال (outbox) و لاگ تلاش‌های ارسال. +""" +from django.conf import settings +from django.db import models + + +class SMSType(models.TextChoices): + """انواع پیامک‌های سیستم.""" + + WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)" + CHEQUE_DUE = "cheque_due", "یادآوری سررسید چک" + NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه" + CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک" + DEBT_DUE = "debt_due", "یادآوری بدهی (سررسید وصول مطالبات)" + VISIT = "visit", "یادآوری بازدید خدمات" + SURVEY = "survey", "نظرسنجی پس از فاکتور" + CUSTOM = "custom", "پیام سفارشی" + + +class SMSProviderConfig(models.Model): + """تنظیمات سامانه پیامک؛ تک‌رکوردی (pk=1) و قابل ویرایش از Django Admin.""" + + class Meta: + verbose_name = "تنظیمات سامانه پیامک" + verbose_name_plural = "تنظیمات سامانه پیامک" + permissions = [ + # نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...) + # تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود. + ("ManageSMS", "ManageSMS"), + ] + + ProviderChoices = [ + ("sms0098", "0098SMS (سرویس لینکی HTTP)"), + ("sms0098ws", "0098SMS (وب‌سرویس + استعلام دلیوری)"), + ("http", "HTTP عمومی"), + ("console", "Console (تست/دیباگ)"), + ] + provider = models.CharField( + max_length=20, choices=ProviderChoices, default="sms0098", + verbose_name="سرویس‌دهنده پیامک", + ) + is_enabled = models.BooleanField( + default=True, verbose_name="فعال بودن ارسال پیامک", + help_text="در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی‌کند.", + ) + api_url = models.CharField( + max_length=500, blank=True, + default="http://www.0098sms.com/sendsmslink.aspx", + verbose_name="آدرس سرویس پیامک (URL)", + help_text="برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.", + ) + username = models.CharField(max_length=100, blank=True, default="", verbose_name="نام کاربری") + password = models.CharField(max_length=200, blank=True, default="", verbose_name="رمز عبور") + sender = models.CharField(max_length=50, blank=True, default="", verbose_name="شماره خط فرستنده") + webservice_url = models.CharField( + max_length=500, blank=True, + default="https://webservice.0098sms.com/service.asmx", + verbose_name="آدرس وب‌سرویس (SOAP/WSDL)", + help_text="فقط برای provider «وب‌سرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام " + "دلیوری از همین آدرس انجام می‌شود.", + ) + panel_no = models.CharField( + max_length=50, blank=True, default="", + verbose_name="شماره پنل (PnlNo)", + help_text="شمارهٔ اختصاصی پنل در وب‌سرویس؛ اگر خالی بماند از «شماره خط فرستنده» " + "استفاده می‌شود.", + ) + api_token = models.CharField(max_length=255, blank=True, default="", verbose_name="توکن API (HTTP)") + test_message = models.TextField( + blank=True, + default="پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.", + verbose_name="متن پیامک آزمایشی", + help_text="متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.", + ) + timeout_seconds = models.PositiveIntegerField(default=30, verbose_name="مهلت ارتباط (ثانیه)") + + # --- کنترل جریان ارسال (جلوگیری از سیل پیامک) --- + scan_since = models.DateTimeField( + null=True, blank=True, + verbose_name="تاریخ شروع دوره", + help_text="فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز " + "می‌شود در صف قرار می‌گیرند؛ موارد قدیمی‌تر (مثل مشتریان خیلی قدیمی که " + "مدت‌هاست بازدید نشده‌اند) نادیده گرفته می‌شوند. از پنل به شکل تاریخ شمسی " + "ویرایش می‌شود و روی «آغاز همان روز» تنظیم می‌گردد. اگر خالی باشد، در " + "نخستین اجرا خودکار روی «همین حالا» تنظیم می‌شود.", + ) + allow_backlog = models.BooleanField( + default=False, + verbose_name="اجازهٔ ارسال موارد قدیمی", + help_text="اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده‌اند هم " + "(به‌اندازهٔ سقف زیر، در هر اجرا) ارسال می‌شوند. به‌صورت پیش‌فرض خاموش است " + "تا مشتریان قدیمی مشمول این فیلتر شوند.", + ) + backlog_max = models.PositiveIntegerField( + default=0, verbose_name="حداکثر موارد قدیمی در هر اجرا", + help_text="فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.", + ) + max_per_run = models.PositiveIntegerField( + default=200, verbose_name="حداکثر پیامک هر اسکنر در هر اجرا", + help_text="سقف کلی صف‌کردن برای هر نوع پیامک در هر اجرای اسکنر.", + ) + send_interval_seconds = models.PositiveIntegerField( + default=3, verbose_name="فاصلهٔ بین هر پیامک (ثانیه)", + help_text="worker پس از هر ارسال این مدت صبر می‌کند تا سرویس‌دهنده زیر بار نرود.", + ) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"تنظیمات پیامک ({self.get_provider_display()})" + + def save(self, *args, **kwargs): + # سینگلتون: همیشه فقط یک رکورد با pk=1. + self.pk = 1 + # نخستین بارگذاری = لحظهٔ فعال‌سازی؛ خط شروع روی «همین حالا» مهر می‌شود تا + # موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نگیرند. + if self.scan_since is None: + from django.utils import timezone + self.scan_since = timezone.now() + super().save(*args, **kwargs) + + @classmethod + def load(cls): + obj, _ = cls.objects.get_or_create(pk=1) + if obj.scan_since is None: + obj.save(update_fields=["scan_since"]) + return obj + + +class SMSTemplate(models.Model): + """قالب متن هر نوع پیامک. متن قابل ویرایش از پنل است.""" + + class Meta: + verbose_name = "قالب پیامک" + verbose_name_plural = "قالب‌های پیامک" + + sms_type = models.CharField( + max_length=40, choices=SMSType.choices, unique=True, verbose_name="نوع پیامک", + ) + title = models.CharField(max_length=100, verbose_name="عنوان") + text = models.TextField( + verbose_name="متن پیامک", + help_text="متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، " + "{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، " + "{check_row} شماره ردیف چک، {owner_name} نام مالک چک.", + ) + is_enabled = models.BooleanField(default=True, verbose_name="فعال") + auto_send = models.BooleanField( + default=False, + verbose_name="ارسال خودکار (بدون تأیید مدیر)", + help_text="اگر خاموش باشد (پیش‌فرض)، پیامک‌های این نوع در وضعیت «در انتظار تأیید» " + "در صف می‌مانند تا مدیر آن‌ها را تأیید کند. با روشن‌کردن، پیامکِ ساخته‌شده " + "توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می‌شود.", + ) + offset_days = models.PositiveSmallIntegerField( + default=0, + verbose_name="فاصلهٔ زمانی (روز)", + help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده می‌شود؛ " + "مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».", + ) + repeat_interval_days = models.PositiveSmallIntegerField( + null=True, blank=True, + verbose_name="فاصله تکرار (روز)", + help_text="خالی بگذارید برای ارسال یک‌باره؛ مقدار بدهید برای ارسال تکرارشونده.", + ) + repeat_interval_months = models.PositiveSmallIntegerField( + null=True, blank=True, + verbose_name="دورهٔ تکرار (ماه)", + help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. خالی = پیش‌فرض سامانه (۹ ماه).", + ) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title + + +class CustomerSMSSetting(models.Model): + """تنظیم فعال/غیرفعال هر نوع پیامک برای یک مشتری (مدل opt-out). + + نبودِ رکورد برای یک (مشتری، نوع) یعنی فعال. تنها تغییرهای صریح ذخیره می‌شوند. + """ + + class Meta: + verbose_name = "تنظیم پیامک مشتری" + verbose_name_plural = "تنظیمات پیامک مشتریان" + unique_together = ("customer", "sms_type") + + customer = models.ForeignKey( + "Customer", on_delete=models.CASCADE, related_name="sms_settings", + ) + sms_type = models.CharField(max_length=40, choices=SMSType.choices, verbose_name="نوع پیامک") + is_enabled = models.BooleanField(default=True, verbose_name="فعال") + + def __str__(self): + state = "فعال" if self.is_enabled else "غیرفعال" + return f"{self.customer} - {self.get_sms_type_display()} ({state})" + + +class SMSQueue(models.Model): + """صف دیتابیسی ارسال پیامک (outbox). توسط worker تخلیه می‌شود.""" + + class Status(models.TextChoices): + PENDING = "pending", "در انتظار تأیید" # در صف بازبینی مدیر + APPROVED = "approved", "تأییدشده" # تأیید مدیر؛ آمادهٔ ارسال توسط worker + PROCESSING = "processing", "در حال پردازش" + SENT = "sent", "ارسال شده" + FAILED = "failed", "ناموفق" + CANCELLED = "cancelled", "لغو شده" + + class DeliveryStatus(models.TextChoices): + """وضعیت تحویل واقعی به گوشی (از استعلام web service، فقط اگر smsid داشته باشیم).""" + PENDING = "pending", "در انتظار استعلام" + DELIVERED = "delivered", "رسیده به گوشی" + NOT_DELIVERED = "not_delivered", "نرسیده به گوشی" + EXPIRED = "expired", "منقضی‌شده" + UNKNOWN = "unknown", "نامشخص" + + customer = models.ForeignKey( + "Customer", on_delete=models.SET_NULL, null=True, blank=True, + related_name="sms_queue", verbose_name="مشتری", + ) + sms_type = models.CharField( + max_length=40, choices=SMSType.choices, blank=True, default="", verbose_name="نوع", + ) + phone_number = models.CharField(max_length=20, verbose_name="شماره گیرنده") + message = models.TextField(verbose_name="متن") + status = models.CharField( + max_length=12, choices=Status.choices, default=Status.PENDING, + db_index=True, verbose_name="وضعیت", + ) + attempts = models.PositiveSmallIntegerField(default=0, verbose_name="تعداد تلاش") + max_attempts = models.PositiveSmallIntegerField(default=5, verbose_name="حداکثر تلاش") + next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name="زمان تلاش بعدی") + last_error = models.TextField(blank=True, default="", verbose_name="آخرین خطا") + provider_id = models.CharField(max_length=100, blank=True, default="") + provider_code = models.CharField(max_length=50, blank=True, default="") + dedup_key = models.CharField( + max_length=120, unique=True, null=True, blank=True, + help_text="کلید یکتاسازی برای جلوگیری از ارسال تکراری.", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + sent_at = models.DateTimeField(null=True, blank=True) + # --- رهگیری تحویل (فقط برای providerهایی که شناسهٔ پیامک برمی‌گردانند) --- + delivery_status = models.CharField( + max_length=16, choices=DeliveryStatus.choices, blank=True, default="", + db_index=True, verbose_name="وضعیت تحویل", + help_text="خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می‌شود.", + ) + delivery_checked_at = models.DateTimeField( + null=True, blank=True, verbose_name="زمان آخرین استعلام دلیوری", + ) + + class Meta: + verbose_name = "صف پیامک" + verbose_name_plural = "صف پیامک‌ها" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["status", "next_retry_at"]), + ] + + def __str__(self): + return f"{self.get_sms_type_display() or self.sms_type} → {self.phone_number} [{self.status}]" + + +class SMSDedup(models.Model): + """دفتر کلیدهای یکتاسازیِ *همیشه‌ثبت‌شده*. + + ``SMSQueue.dedup_key`` تنها تا وقتی جلوی تکرار را می‌گیرد که رکورد صف باقی + باشد؛ با حذف رکورد از صف (رد کردن پیامک توسط مدیر) کلید هم پاک می‌شد و اسکنر + در اجرای بعدی همان پیامک را دوباره می‌ساخت. این دفتر مستقل از صف است و هرگز + حذف نمی‌شود، پس پیامکِ حذف‌شده دیگر بازنمی‌گردد. + """ + + class Meta: + verbose_name = "کلید پردازش‌شدهٔ پیامک" + verbose_name_plural = "کلیدهای پردازش‌شدهٔ پیامک" + + key = models.CharField(max_length=120, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.key + + +class SMSLog(models.Model): + """لاگ سطح-تلاش برای هر بار ارسال توسط worker.""" + + class Meta: + verbose_name = "لاگ پیامک" + verbose_name_plural = "لاگ‌های پیامک" + ordering = ["-created_at"] + + queue = models.ForeignKey( + SMSQueue, on_delete=models.CASCADE, related_name="logs", verbose_name="صف", + ) + attempt_no = models.PositiveSmallIntegerField(default=1, verbose_name="شماره تلاش") + success = models.BooleanField(default=False, verbose_name="موفق") + provider_id = models.CharField(max_length=100, blank=True, default="") + provider_code = models.CharField(max_length=50, blank=True, default="") + error = models.TextField(blank=True, default="", verbose_name="خطا") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + state = "موفق" if self.success else "ناموفق" + return f"#{self.queue_id} تلاش {self.attempt_no} ({state})" diff --git a/admin_panel/signals.py b/admin_panel/signals.py new file mode 100644 index 0000000..53b4dcf --- /dev/null +++ b/admin_panel/signals.py @@ -0,0 +1,19 @@ +"""سیگنال‌های اپ admin_panel.""" +import logging + +from django.db.backends.signals import connection_created +from django.dispatch import receiver + +logger = logging.getLogger(__name__) + + +@receiver(connection_created) +def configure_sqlite_connection(sender, connection, **kwargs): + """افزایش busy_timeout برای SQLite تا نوشتن هم‌زمان web/worker/scheduler خطا ندهد.""" + if connection.vendor != "sqlite": + return + try: + with connection.cursor() as cursor: + cursor.execute("PRAGMA busy_timeout=30000;") + except Exception: + logger.warning("تنظیم busy_timeout برای SQLite ناموفق بود.", exc_info=True) diff --git a/admin_panel/sms/__init__.py b/admin_panel/sms/__init__.py new file mode 100644 index 0000000..98af557 --- /dev/null +++ b/admin_panel/sms/__init__.py @@ -0,0 +1 @@ +"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر).""" diff --git a/admin_panel/sms/enqueue.py b/admin_panel/sms/enqueue.py new file mode 100644 index 0000000..b316139 --- /dev/null +++ b/admin_panel/sms/enqueue.py @@ -0,0 +1,74 @@ +"""قرار دادن پیامک در صف ارسال (insert-only، بی‌خطر برای مسیر request).""" +import logging +from uuid import uuid4 + +from django.db import IntegrityError + +from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType +from .recipients import resolve_recipients +from .render import render_template +from .rules import is_sms_enabled + +logger = logging.getLogger(__name__) + + +def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=None, + message=None, recipients=None, created_by=None, approved=False): + """پیامک را در صف قرار می‌دهد و تعداد رکوردهای ساخته‌شده را برمی‌گرداند. + + به‌صورت پیش‌فرض پیامک در وضعیت «در انتظار تأیید» ثبت می‌شود و تا وقتی مدیر + آن را از پنل تأیید نکند، worker آن را ارسال نمی‌کند. دو راه برای پرش از این + بازبینی هست: ``approved=True`` برای ارسال‌های دستیِ صریح مدیر (مثل پیامک سفارشی + یا آزمایشی)، و ``SMSTemplate.auto_send`` که مدیر در صفحهٔ قالب‌ها برای هر نوع + پیامک روشن می‌کند تا پیامک‌های خودکارِ اسکنر بدون صف تأیید ارسال شوند. + + هیچ استثنایی به فراخوان پرتاب نمی‌شود؛ خطاها فقط لاگ می‌شوند تا مسیر اصلی + (مثلاً ایجاد مشتری یا اسکنر) هرگز به‌خاطر پیامک مختل نشود. + """ + try: + if message is None: + template = SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first() + if template is None: + return 0 + message = render_template(template.text, context) + # «ارسال خودکار» هر نوع پیامک در صفحهٔ قالب‌ها تعیین می‌شود؛ خاموش = بازبینی مدیر. + approved = approved or template.auto_send + + if not is_sms_enabled(customer, sms_type): + return 0 + + numbers = recipients if recipients is not None else resolve_recipients(customer) + if not numbers: + return 0 + + if dedup_key is None: + dedup_key = f"{sms_type}:{uuid4().hex}" + + created = 0 + for number in numbers: + key = dedup_key if len(numbers) == 1 else f"{dedup_key}:{number}" + try: + # دفتر کلیدها مستقل از صف است: کلیدِ یک‌بار خورده هرگز دوباره + # ایجاد نمی‌شود، حتی اگر رکورد صف را مدیر حذف کرده باشد. + if not SMSDedup.objects.get_or_create(key=key)[1]: + continue + _, made = SMSQueue.objects.get_or_create( + dedup_key=key, + defaults={ + "customer": customer, + "sms_type": sms_type, + "phone_number": number, + "message": message, + "created_by": created_by, + "status": (SMSQueue.Status.APPROVED if approved + else SMSQueue.Status.PENDING), + }, + ) + created += 1 if made else 0 + except IntegrityError: + # رقابت هم‌زمان روی dedup_key؛ نادیده گرفته می‌شود. + continue + return created + except Exception: + logger.exception("enqueue_sms برای نوع=%s شکست خورد", sms_type) + return 0 diff --git a/admin_panel/sms/notices.py b/admin_panel/sms/notices.py new file mode 100644 index 0000000..492bdfd --- /dev/null +++ b/admin_panel/sms/notices.py @@ -0,0 +1,59 @@ +"""پیام‌های نتیجهٔ عملیات پنل پیامک. + +جایگزین flash message جنگو است: پیام‌های جنگو در session ذخیره می‌شوند و اگر +قالب مقصد آن‌ها را رندر نکند، تا صفحهٔ بعدِ دارای بلوک پیام «سرگردان» می‌مانند و +بی‌ربط به عملیات ظاهر می‌شوند. این‌جا پیام با یک کد در querystring +(``?notice=``) رد و بدل می‌شود؛ چون کد فقط روی همان URL وجود دارد، پیام هم +فقط همان‌جا دیده می‌شود و هیچ‌جا سرگردان نمی‌ماند. + +مقدار ``{placeholder}`` در متن از پارامترهای querystring پر می‌شود +(نبودِ پارامتر ⇒ رشتهٔ خالی). +""" +from collections import defaultdict + +# کد → (شدت، متن). شدت به کلاس Bulma نگاشت می‌شود. +NOTICES = { + # --- تنظیمات و ارسال آزمایشی --- + "test_sent": ("success", "پیامک آزمایشی در صف ارسال قرار گرفت."), + "settings_saved": ("success", "تنظیمات سامانه پیامک ذخیره شد."), + # --- قالب‌ها --- + "tpl_saved": ("success", "قالب «{title}» ذخیره شد."), + "tpl_bad_timing": ("error", "مقدار زمان‌بندی باید یک عدد صحیح باشد."), + # --- مشتری --- + "customer_sms_saved": ("success", "تنظیم پیامک مشتری به‌روزرسانی شد."), + "primary_updated": ("success", "شمارهٔ اصلی پیامک به‌روزرسانی شد."), + "primary_not_found": ("error", "شماره موردنظر برای این مشتری یافت نشد."), + "custom_queued": ("success", "پیامک سفارشی در صف ارسال قرار گرفت."), + "custom_empty": ("error", "متن پیامک خالی است."), + "custom_no_phone": ("error", "شماره اصلی معتبری برای این مشتری یافت نشد."), + # --- صف --- + "approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."), + "not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."), + "deleted": ("success", "پیامک از صف حذف شد."), + "bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."), + "bulk_approved": ("success", "{count} پیامک تأیید شد."), + "bulk_deleted": ("success", "{count} پیامک از صف حذف شد."), + "invalid_action": ("error", "اقدام نامعتبر است."), + "queue_emptied": ("success", "کل صف خالی شد ({count} پیامک حذف شد)."), + "retried": ("success", "پیامک برای ارسال مجدد تأیید شد."), +} + +_KIND_CLASSES = { + "success": "is-success", + "error": "is-danger", + "warning": "is-warning", + "info": "is-info", +} + + +def resolve(request): + """کد ``?notice=`` را به متن/کلاس قابل رندر در قالب تبدیل می‌کند.""" + get = getattr(request, "GET", None) or {} + entry = NOTICES.get((get.get("notice") or "").strip()) + if not entry: + return {} + kind, text = entry + return { + "notice_text": text.format_map(defaultdict(str, get)), + "notice_kind": _KIND_CLASSES.get(kind, "is-info"), + } diff --git a/admin_panel/sms/phone_cleanup.py b/admin_panel/sms/phone_cleanup.py new file mode 100644 index 0000000..78e77aa --- /dev/null +++ b/admin_panel/sms/phone_cleanup.py @@ -0,0 +1,139 @@ +"""پاک‌سازی یک‌بارهٔ شماره‌های قدیمی مشتریان (فیلدهای ``phone1``/``phone2``/``phone3``). + +سال‌ها شماره‌ها در سه فیلد متنی ذخیره شده‌اند و به هر شکلی وارد شده‌اند: +بدون صفر ابتدایی (``9133722815``)، با ارقام فارسی، با فاصله/خط تیره، یا کلاهی +متن آزاد (نام، تاریخ نصب، «معرف …»). این ماژول قاعدهٔ تبدیل را یک‌جا تعریف می‌کند +تا هم مهاجرت (اجرای خودکار، یک‌بار) و هم دستور ``fix_customer_phones`` (اجرای +دستی روی دیتابیس production) از یک پیاده‌سازی استفاده کنند. + +قاعده (طبق تصمیم کاربر): + +* شمارهٔ موبایل — هرجای متن که باشد — استخراج و به شکل استاندارد ``09xxxxxxxxx`` + تبدیل می‌شود. +* شمارهٔ ثابت ۸رقمی (مثل ``57387608``) **دست‌نخورده** می‌ماند. +* بقیه (متن آزاد، تاریخ، تکه‌های بی‌معنی) از فیلد شماره خالی می‌شود و متن اصلی‌اش + در فیلد ``Customer.notes`` می‌رود تا چیزی از سیستم گم نشود. + +تابع ``clean_legacy_cell`` خودبسنده و idempotent است: بعد از یک‌بار اجرا، خروجی +همان مقدار فعلی است و دیگر توضیحی اضافه نمی‌شود؛ پس اجرای دوباره بی‌خطر است. +""" +import re + +from persiantools import digits + +from integrations.sms.normalize import normalize_mobile, to_local + +_NOTE_HEADER = "— پاک‌سازی خودکار فیلدهای تلفن —" + + +def _numeric_tokens(raw): + """تکه‌های رقمی متن، پس از تبدیل ارقام فارسی/عربی به انگلیسی.""" + text = digits.fa_to_en(digits.ar_to_fa(str(raw))) + return re.findall(r"\d+", text) + + +def clean_legacy_cell(raw): + """یک فیلد تلفن را پاک‌سازی می‌کند. + + خروجی ``(value, note)`` است: + + * ``value`` مقدار جدید فیلد، یا ``None`` یعنی «دست نزن». + * ``note`` متن اصلی برای افزودن به ``Customer.notes``، یا ``None``. + + قاعده: هرچیزِ فیلد که در مقدار جدید نمی‌ماند، **بی‌کم‌وکاست** در + ``note`` برمی‌گردد تا هیچ داده‌ای گم نشود. + """ + original = (raw or "").strip() + if not original: + return None, None + + # ۱) کل فیلد یک موبایل است (شاید با فاصله/خط تیره/پیش‌شماره). + whole = normalize_mobile(original) + if whole: + local = to_local(whole) + if local == original: + return None, None + return local, original + + tokens = _numeric_tokens(original) + + # ۲) موبایلی داخل متن پنهان است (مثل «57322182/9130261671» یا «موبایل: 0912…»). + for token in tokens: + local = to_local(token) + if local: + return local, original + + # ۳) شمارهٔ ثابت ۸رقمیِ تنها ⇒ دست‌نخورده. + if len(tokens) == 1 and len(tokens[0]) == 8: + return None, None + + # ۴) بقیه (متن آزاد، تاریخ، تکهٔ بی‌معنی) ⇒ خالی؛ متن اصلی در یادداشت می‌ماند. + return "", original + + +def _append_note(notes, lines): + """خطوط توضیح را به انتهای یادداشت مشتری اضافه می‌کند (با سرصفحهٔ یک‌بار).""" + current = (notes or "").strip() + block = "\n".join(lines) + if _NOTE_HEADER in current: + return f"{current}\n{block}" + if current: + return f"{current}\n\n{_NOTE_HEADER}\n{block}" + return f"{_NOTE_HEADER}\n{block}" + + +def clean_customer(customer, *, dry_run=False): + """فیلدهای تلفن یک مشتری را اصلاح و متن‌های حذف‌شده را در ``notes`` می‌ریزد. + + خروجی: تعداد فیلدهای تغییرکرده. اگر تغییری نباشد، چیزی ذخیره نمی‌شود. + """ + changed = 0 + note_lines = [] + updates = {} + for field in ("phone1", "phone2", "phone3"): + value, note = clean_legacy_cell(getattr(customer, field, "")) + if value is not None and value != (getattr(customer, field) or ""): + updates[field] = value + changed += 1 + if note: + note_lines.append(f"«{field}»: {note}") + + if not changed and not note_lines: + return 0 + if dry_run: + return changed or (1 if note_lines else 0) + + if note_lines: + customer.notes = _append_note(customer.notes, note_lines) + updates["notes"] = customer.notes + if updates: + for field, value in updates.items(): + setattr(customer, field, value) + customer.save(update_fields=list(updates)) + return changed + + +def clean_all(*, dry_run=False, batch_size=500, apps=None): + """همهٔ مشتریان را پاک‌سازی می‌کند. خروجی: دیکشنری آمار. + + ``apps`` فقط برای مهاجرت‌ها است تا مدل را از رجیستری تاریخی بگیرد (نه import + زمان‌اجرا)؛ در غیر این صورت مدل زندهٔ ``Customer`` استفاده می‌شود. + """ + if apps is not None: + Customer = apps.get_model("admin_panel", "Customer") + else: + from ..models import Customer + + stats = {"customers": 0, "fields": 0, "noted": 0} + qs = Customer.objects.all().only( + "id", "phone1", "phone2", "phone3", "notes" + ).order_by("id") + for customer in qs.iterator(chunk_size=batch_size): + before_notes = (customer.notes or "").strip() + changed = clean_customer(customer, dry_run=dry_run) + if changed: + stats["customers"] += 1 + stats["fields"] += changed + if (not dry_run) and (customer.notes or "").strip() != before_notes: + stats["noted"] += 1 + return stats diff --git a/admin_panel/sms/phones.py b/admin_panel/sms/phones.py new file mode 100644 index 0000000..b96b449 --- /dev/null +++ b/admin_panel/sms/phones.py @@ -0,0 +1,163 @@ +"""یافتن شماره‌های یک مشتری از همهٔ روش‌های ذخیره‌سازی. + +شماره‌ها در این پروژه به چند شکل ذخیره می‌شوند: + +* رکوردهای مدل ``PhoneNumber`` (روش جدید؛ عنوان + شماره) +* فیلدهای قدیمی ``Customer.phone1`` / ``phone2`` / ``phone3`` +* بدون صفر ابتدایی (``9133722815`` به‌جای ``09133722815``) و بعضاً مقدارهای + بی‌معنی مثل ``'0'`` یا شمارهٔ ثابت. + +این ماژول تنها مرجع خواندن شماره‌هاست: هر جای دیگر (پنل، اسکنر، worker) باید از +همین توابع استفاده کند تا رفتار یکسان باشد. + +«شمارهٔ اصلی» به‌صورت **مقدارِ خودِ شماره** (نرمال‌شده) در ``Customer.sms_primary_phones`` +ذخیره می‌شود، نه با کلید رکورد. دلیل: دادهٔ مشتریان دائماً تغییر می‌کند (افزودن، ویرایش، +حذف) و کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره یک مقدار پایدار است. اگر شماره‌ای +از مشتری حذف یا ویرایش شود، انتخاب اصلی خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول +برمی‌گردد. +""" +from integrations.sms.normalize import normalize_mobile + + +class PhoneEntry: + """یک شمارهٔ مشتری، مستقل از اینکه کجا ذخیره شده است.""" + + __slots__ = ("raw", "display", "normalized", "title", "source", "is_primary") + + def __init__(self, raw, title, source, is_primary=False): + self.raw = raw + self.title = title + self.source = source + self.normalized = normalize_mobile(raw) + self.display = to_display(raw) + self.is_primary = is_primary + + @property + def is_mobile(self): + return bool(self.normalized) + + def as_dict(self): + return { + "raw": self.raw, + "display": self.display, + "normalized": self.normalized, + "title": self.title, + "source": self.source, + "is_primary": self.is_primary, + "is_mobile": self.is_mobile, + } + + def __repr__(self): # pragma: no cover - کمک‌کنندهٔ دیباگ + return f"" + + +def to_display(raw): + """شماره را برای نمایش با صفر ابتدایی برمی‌گرداند (بدون دست‌بردن در مقدار ذخیره‌شده).""" + text = "" if raw is None else str(raw).strip() + if not text: + return "" + normalized = normalize_mobile(text) + if normalized: + return "0" + normalized[3:] # +989xxxxxxxxx → 09xxxxxxxxx + return text + + +def _iter_sources(customer): + """(title, raw, source) از همهٔ منابع، بدون تکرار — ابتدا رکوردها، سپس فیلدهای قدیمی.""" + from ..models import PhoneNumber + + seen = set() + rows = PhoneNumber.objects.filter(customer=customer).order_by("id") + for row in rows: + raw = (row.phone_number or "").strip() + if not raw: + continue + key = normalize_mobile(raw) or raw + if key in seen: + continue + seen.add(key) + yield (row.title or "تلفن", raw, "phone_number") + + legacy = ( + ("تلفن 1", getattr(customer, "phone1", "")), + ("تلفن 2", getattr(customer, "phone2", "")), + ("تلفن 3", getattr(customer, "phone3", "")), + ) + for title, raw in legacy: + raw = (raw or "").strip() + if not raw: + continue + key = normalize_mobile(raw) or raw + if key in seen: + continue + seen.add(key) + yield (title, raw, "legacy") + + +def stored_primary_normalized(customer): + """مجموعهٔ شماره‌های نرمال‌شده‌ای که مدیر به‌عنوان «اصلی» انتخاب کرده است.""" + raw = (getattr(customer, "sms_primary_phones", "") or "").strip() + if not raw: + return set() + return {part.strip() for part in raw.split(",") if part.strip()} + + +def normalize_primary_values(values): + """فهرست شماره‌های خام → رشتهٔ نرمال‌شدهٔ یکتا برای ذخیره در ``sms_primary_phones``. + + شماره‌های نامعتبر (ثابت، ناقص، ``'0'``) نادیده گرفته می‌شوند؛ پس علامت‌زدن یک شمارهٔ + غیرموبایل به‌عنوان «اصلی» بی‌اثر می‌ماند و سیستم به شمارهٔ اولِ معتبر برمی‌گردد. + """ + unique = [] + for raw in values: + number = normalize_mobile(raw) + if number and number not in unique: + unique.append(number) + return ",".join(unique) + + +def save_primary_normalized(customer, numbers, *, update_fields=("sms_primary_phones",)): + """ذخیرهٔ فهرست شماره‌های اصلی (نرمال‌شده، بدون تکرار، به ترتیب ورودی).""" + customer.sms_primary_phones = normalize_primary_values(numbers) + customer.save(update_fields=list(update_fields)) + return stored_primary_normalized(customer) + + +def customer_phone_entries(customer): + """همهٔ شماره‌های مشتری از هر منبعی که ذخیره شده باشند (بدون تکرار).""" + if customer is None: + return [] + primary = stored_primary_normalized(customer) + return [ + PhoneEntry(raw, title, source, is_primary=(entry_norm in primary) if entry_norm else False) + for title, raw, source in _iter_sources(customer) + for entry_norm in [normalize_mobile(raw)] + ] + + +def usable_entries(customer): + """فقط شماره‌هایی که موبایل معتبرند و واقعاً قابل ارسال پیامک هستند.""" + return [entry for entry in customer_phone_entries(customer) if entry.is_mobile] + + +def primary_phones(customer): + """شماره‌های خام گیرندهٔ پیامک: انتخاب‌شده‌های مدیر، وگرنه شمارهٔ اولِ قابل استفاده.""" + entries = usable_entries(customer) + if not entries: + return [] + chosen = [entry for entry in entries if entry.is_primary] + if chosen: + return [entry.raw for entry in chosen] + return [entries[0].raw] + + +def resolved_recipients(customer): + """گیرندگان نهاییِ نرمال‌شده (``+989xxxxxxxxx``) و یکتا.""" + seen = [] + for entry in usable_entries(customer): + if entry.is_primary and entry.normalized not in seen: + seen.append(entry.normalized) + if seen: + return seen + entries = usable_entries(customer) + return [entries[0].normalized] if entries else [] diff --git a/admin_panel/sms/recipients.py b/admin_panel/sms/recipients.py new file mode 100644 index 0000000..906124c --- /dev/null +++ b/admin_panel/sms/recipients.py @@ -0,0 +1,22 @@ +"""تعیین گیرندگان پیامک. + +منطق واقعیِ یافتن شماره‌ها در ``admin_panel.sms.phones`` است (تنها مرجع خواندن +شماره‌ها). این ماژول فقط نام‌های قدیمی را برای سازگاری نگه می‌دارد و آن‌ها را به +همان توابع واگذار می‌کند تا رفتار در همهٔ مسیرها یکسان بماند. +""" +import logging + +from .phones import primary_phones as _primary_phones +from .phones import resolved_recipients + +logger = logging.getLogger(__name__) + + +def primary_phones(customer): + """شماره‌های خام گیرندهٔ پیامک (اصلی‌های انتخاب‌شده، وگرنه شمارهٔ اولِ معتبر).""" + return _primary_phones(customer) + + +def resolve_recipients(customer): + """فهرست شماره‌های نرمال‌شدهٔ یکتای گیرنده.""" + return resolved_recipients(customer) diff --git a/admin_panel/sms/render.py b/admin_panel/sms/render.py new file mode 100644 index 0000000..a58488f --- /dev/null +++ b/admin_panel/sms/render.py @@ -0,0 +1,41 @@ +"""رندر قالب متن پیامک با placeholderهای امن.""" +from persiantools.jdatetime import JalaliDate, JalaliDateTime + + +class _SafeDict(dict): + """دیکشنری که برای کلیدهای ناشناخته رشته خالی برمی‌گرداند (بدون خطا).""" + + def __missing__(self, key): + return "" + + +def format_amount(value): + try: + return f"{int(value):,}" + except (TypeError, ValueError): + return str(value or "") + + +def format_jalali(value): + """تبدیل datetime/date به تاریخ جلالی ``YYYY/MM/DD``.""" + if not value: + return "" + try: + if hasattr(value, "date") and callable(getattr(value, "date")): + value = value.date() + return JalaliDate(value).strftime("%Y/%m/%d") + except Exception: + try: + return JalaliDateTime(value).strftime("%Y/%m/%d") + except Exception: + return str(value) + + +def render_template(text, context=None): + ctx = dict(context or {}) + if "amount" in ctx: + ctx.setdefault("amount_sep", format_amount(ctx["amount"])) + if "date" in ctx: + ctx.setdefault("date_raw", ctx["date"]) + ctx["date"] = format_jalali(ctx["date"]) + return (text or "").format_map(_SafeDict(ctx)) diff --git a/admin_panel/sms/rules.py b/admin_panel/sms/rules.py new file mode 100644 index 0000000..ab94640 --- /dev/null +++ b/admin_panel/sms/rules.py @@ -0,0 +1,25 @@ +"""قواعد فعال/غیرفعال بودن پیامک برای مشتری (مدل opt-out).""" +from ..models import CustomerSMSSetting, SMSType + + +def is_sms_enabled(customer, sms_type): + """اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر این‌صورت فعال.""" + if customer is None or sms_type == SMSType.CUSTOM: + return True + setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first() + if setting is None: + return True + return setting.is_enabled + + +def customer_toggle_map(customer): + """فهرست نوع‌های پیامک با وضعیت مؤثر (برای نمایش در پنل مشتری).""" + overrides = { + s.sms_type: s.is_enabled + for s in CustomerSMSSetting.objects.filter(customer=customer) + } + return [ + {"code": code, "label": label, "enabled": overrides.get(code, True)} + for code, label in SMSType.choices + if code != SMSType.CUSTOM + ] diff --git a/admin_panel/sms/scanner.py b/admin_panel/sms/scanner.py new file mode 100644 index 0000000..1d00acf --- /dev/null +++ b/admin_panel/sms/scanner.py @@ -0,0 +1,336 @@ +"""اسکنر دوره‌ای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف. + +قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم): + +* **خط شروع (``scan_since``)**: فقط مواردی صف می‌شوند که «لحظهٔ باز شدن پنجرهٔ یادآوری» + آن‌ها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچ‌کدام از موارد قدیمی موجود + در دیتابیس یک‌باره ارسال نمی‌شوند. +* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند، + در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود. +* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا. + +هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از +ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد. + +یادآوری‌های **دوره‌ای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر +مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره +ساخته می‌شود؛ برای همیشه خاموش نمی‌ماند. +""" +import logging +import os +from datetime import datetime, time, timedelta + +from django.db.models import Q, Sum +from django.utils import timezone + +from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig, + SMSTemplate, SMSType) +from .enqueue import enqueue_sms +from .timing import DEFAULT_VISIT_MONTHS + +logger = logging.getLogger(__name__) + +# پیش‌فرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد. +CHEQUE_REG_INTERVAL_DEFAULT = 3 + + + +def _template(sms_type): + return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first() + + +def _aware(value): + """تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند).""" + if value is None: + return None + if isinstance(value, datetime): + if timezone.is_naive(value): + return timezone.make_aware(value, timezone.get_current_timezone()) + return value + # date → آغاز همان روز + return timezone.make_aware( + datetime.combine(value, time.min), timezone.get_current_timezone(), + ) + + +class _Budget: + """ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل).""" + + def __init__(self, config): + self.since = config.scan_since + self.allow_backlog = bool(config.allow_backlog) + self.backlog_max = config.backlog_max or 0 + self.max_per_run = config.max_per_run or 0 + self.new_count = 0 + self.backlog_count = 0 + + def is_backlog(self, trigger): + trigger = _aware(trigger) + return self.since is not None and trigger < self.since + + def can_send(self, trigger): + if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run: + return False + if self.is_backlog(trigger): + return self.allow_backlog and self.backlog_count < self.backlog_max + return True + + def record(self, trigger): + if self.is_backlog(trigger): + self.backlog_count += 1 + else: + self.new_count += 1 + + +def scan_cheque_due(today): + template = _template(SMSType.CHEQUE_DUE) + if template is None: + return 0 + budget = _Budget(SMSProviderConfig.load()) + offset = template.offset_days or 0 + qs = Sarresid.objects.filter( + type="check", payStatus="false", + sarresidDT__date__gte=today, + sarresidDT__date__lte=today + timedelta(days=offset), + ).select_related("customer").order_by("sarresidDT") + count = 0 + for s in qs: + if not s.customer or not s.sarresidDT: + continue + # پنجرهٔ یادآوری از «سررسید منهای offset» باز می‌شود. + trigger = _aware(s.sarresidDT) - timedelta(days=offset) + if not budget.can_send(trigger): + continue + days = (s.sarresidDT.date() - today).days + made = enqueue_sms( + customer=s.customer, sms_type=SMSType.CHEQUE_DUE, + context={ + "name": s.customer.name_family, "amount": s.price, + "date": s.sarresidDT, "days": days, "check_row": s.check_row_number, + }, + dedup_key=f"cheque_due:{s.id}", + ) + if made: + budget.record(trigger) + count += made + return count + + +def scan_nesiye_due(today): + template = _template(SMSType.NESIYE_DUE) + if template is None: + return 0 + budget = _Budget(SMSProviderConfig.load()) + offset = template.offset_days or 0 + qs = Sarresid.objects.filter( + type="nesiye", payStatus="false", + sarresidDT__date__gte=today, + sarresidDT__date__lte=today + timedelta(days=offset), + ).select_related("customer").order_by("sarresidDT") + count = 0 + for s in qs: + if not s.customer or not s.sarresidDT: + continue + trigger = _aware(s.sarresidDT) - timedelta(days=offset) + if not budget.can_send(trigger): + continue + days = (s.sarresidDT.date() - today).days + made = enqueue_sms( + customer=s.customer, sms_type=SMSType.NESIYE_DUE, + context={ + "name": s.customer.name_family, "amount": s.price, + "date": s.sarresidDT, "days": days, + }, + dedup_key=f"nesiye_due:{s.id}", + ) + if made: + budget.record(trigger) + count += made + return count + + +def scan_cheque_registration(today): + """یادآوری «ثبت اطلاعات چک» برای چک‌های پرداخت‌نشدهٔ ثبت‌نشده. + + هر ``interval`` روز یک‌بار تکرار می‌شود و به‌محض اینکه شمارهٔ صیادی چک ثبت شود + (یا چک پرداخت شود) متوقف می‌گردد. دورهٔ تکرار از پنل قابل تنظیم است. + """ + template = _template(SMSType.CHEQUE_REGISTRATION) + if template is None: + return 0 + budget = _Budget(SMSProviderConfig.load()) + interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT + qs = Sarresid.objects.filter( + type="check", payStatus="false", sarresidDT__date__gte=today, + ).filter( + Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True), + ).select_related("customer") + count = 0 + for s in qs: + if not s.customer: + continue + anchor = s.createdDT or s.sarresidDT + if not anchor: + continue + days = (today - anchor.date()).days + if days <= 0 or days % interval != 0: + continue + cycle = days // interval + # لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است. + trigger = _aware(anchor) + timedelta(days=days) + if not budget.can_send(trigger): + continue + made = enqueue_sms( + customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION, + context={ + "name": s.customer.name_family, "amount": s.price, + "date": s.sarresidDT, "days": (s.sarresidDT.date() - today).days + if s.sarresidDT else "", + "check_row": s.check_row_number, + }, + dedup_key=f"cheque_reg:{s.id}:{cycle}", + ) + if made: + budget.record(trigger) + count += made + return count + + +def scan_debt_due(today): + """یادآوری بدهی برای مانده‌دارها روی سررسید وصول مطالبات.""" + template = _template(SMSType.DEBT_DUE) + if template is None: + return 0 + budget = _Budget(SMSProviderConfig.load()) + offset = template.offset_days or 0 + target = today - timedelta(days=offset) + qs = Sarresid.objects.filter( + payStatus="false", sarresidDT__date=target, + ).select_related("customer").order_by("sarresidDT") + count = 0 + seen = set() + for s in qs: + if not s.customer or s.customer_id in seen or not s.sarresidDT: + continue + trigger = _aware(s.sarresidDT) + if not budget.can_send(trigger): + continue + totals = Article.objects.filter(customer=s.customer).aggregate( + d=Sum("debit"), c=Sum("credit"), + ) + net = (totals["d"] or 0) - (totals["c"] or 0) + if net <= 0: + continue + made = enqueue_sms( + customer=s.customer, sms_type=SMSType.DEBT_DUE, + context={"name": s.customer.name_family, "amount": net}, + dedup_key=f"debt_due:{s.id}", + ) + if made: + seen.add(s.customer_id) + budget.record(trigger) + count += made + return count + + +def scan_survey(today): + """نظرسنجی چند روز پس از ایجاد فاکتور.""" + template = _template(SMSType.SURVEY) + if template is None: + return 0 + budget = _Budget(SMSProviderConfig.load()) + offset = template.offset_days or 3 + target = today - timedelta(days=offset) + qs = Factor.objects.filter( + is_active=True, create_date__date=target, + ).exclude(pay_status="cancell").select_related("customer").order_by("create_date") + count = 0 + for f in qs: + if not f.customer or not f.create_date: + continue + trigger = _aware(f.create_date) + timedelta(days=offset) + if not budget.can_send(trigger): + continue + made = enqueue_sms( + customer=f.customer, sms_type=SMSType.SURVEY, + context={"name": f.customer.name_family, "amount": f.amount}, + dedup_key=f"survey:{f.id}", + ) + if made: + budget.record(trigger) + count += made + return count + + +def scan_visit(today): + """یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»). + + لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای + مشتریانی که هرگز بازدید نشده‌اند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی + تلقی می‌شود و فقط با اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد. + """ + template = _template(SMSType.VISIT) + if template is None: + return 0 + budget = _Budget(SMSProviderConfig.load()) + # دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیش‌فرض. + months = template.repeat_interval_months + if not months: + try: + months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS)) + except (TypeError, ValueError): + months = DEFAULT_VISIT_MONTHS + period = timedelta(weeks=int(months * 4.3)) + period_days = max(period.days, 1) + now = timezone.now() + qs = Customer.objects.filter( + laghv_bazdid=False, + is_active=True, + installDate__lte=(now - period).date(), + ).order_by("id") + count = 0 + for customer in qs: + anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate) + if anchor is None: + continue + trigger = anchor + period + if trigger > now: + continue # هنوز عقب‌افتاده نیست + if not budget.can_send(trigger): + continue + # چرخه = شمارهٔ دورهٔ کاملِ سپری‌شده از لنگر. کلید به چرخه گره می‌خورد تا + # حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دوره‌های بعدی + # دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند). + cycle = max((today - anchor.date()).days // period_days, 0) + made = enqueue_sms( + customer=customer, sms_type=SMSType.VISIT, + context={"name": customer.name_family}, + dedup_key=f"visit:{customer.id}:{cycle}", + ) + if made: + budget.record(trigger) + count += made + return count + + +SCANNERS = [ + ("cheque_due", scan_cheque_due), + ("nesiye_due", scan_nesiye_due), + ("cheque_registration", scan_cheque_registration), + ("debt_due", scan_debt_due), + ("survey", scan_survey), + ("visit", scan_visit), +] + + +def run_all(today=None): + """همهٔ اسکنرها را اجرا و نگاشت «نام → تعداد درج‌شده» را برمی‌گرداند.""" + today = today or timezone.localdate() + results = {} + for name, func in SCANNERS: + try: + results[name] = func(today) + except Exception: + logger.exception("اسکن پیامک شکست خورد: %s", name) + results[name] = -1 + return results diff --git a/admin_panel/sms/timing.py b/admin_panel/sms/timing.py new file mode 100644 index 0000000..75ff32e --- /dev/null +++ b/admin_panel/sms/timing.py @@ -0,0 +1,112 @@ +"""معنای «زمان‌بندی» برای هر نوع پیامک. + +هر نوع پیامک زمان‌بندی متفاوتی دارد و نمی‌شود همه را زیر یک برچسب «روز قبل از سررسید» +جمع کرد. این ماژول تنها مرجع این معناست: هم پنل قالب‌ها بر اساسش فیلد درست را نشان +می‌دهد، هم اسکنر بر اساسش تصمیم می‌گیرد. هر نوع دقیقاً به یک فیلد زمانی نگاشت می‌شود +(``field``) تا داده هرگز ناهمخوان نشود؛ بقیهٔ فیلدهای زمانی صفر/خالی می‌مانند. +""" +from ..models import SMSType + +NONE = "none" +BEFORE_DUE = "before_due" +AFTER_CREATION = "after_creation" +RECURRING = "recurring" +PERIOD_MONTHS = "period_months" + +# پیش‌فرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد. +DEFAULT_VISIT_MONTHS = 9 + +TIMING = { + SMSType.WELCOME: { + "kind": NONE, + "field": None, + "note": "به‌محض ثبت مشتری و بدون تأخیر ارسال می‌شود؛ تنظیم زمانی ندارد.", + }, + SMSType.CHEQUE_DUE: { + "kind": BEFORE_DUE, + "field": "offset_days", + "label": "چند روز قبل از سررسید چک", + "default": 2, + "note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.", + }, + SMSType.NESIYE_DUE: { + "kind": BEFORE_DUE, + "field": "offset_days", + "label": "چند روز قبل از سررسید نسیه", + "default": 2, + "note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.", + }, + SMSType.CHEQUE_REGISTRATION: { + "kind": RECURRING, + "field": "repeat_interval_days", + "label": "هر چند روز یک‌بار", + "default": 3, + "note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، " + "با این فاصله تکرار می‌شود.", + }, + SMSType.SURVEY: { + "kind": AFTER_CREATION, + "field": "offset_days", + "label": "چند روز بعد از صدور فاکتور", + "default": 3, + "note": "این تعداد روز پس از ثبت فاکتور ارسال می‌شود.", + }, + SMSType.DEBT_DUE: { + "kind": NONE, + "field": None, + "note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال می‌شود.", + }, + SMSType.VISIT: { + "kind": PERIOD_MONTHS, + "field": "repeat_interval_months", + "label": "هر چند ماه یک‌بار", + "default": DEFAULT_VISIT_MONTHS, + "note": "برای مشتریانی که از این مدت بازدید نشده‌اند ارسال می‌شود.", + }, + SMSType.CUSTOM: { + "kind": NONE, + "field": None, + "note": "فقط به‌صورت دستی از صفحهٔ مشتری ارسال می‌شود؛ تنظیم زمانی ندارد.", + }, +} + + +def timing_for(sms_type): + """اطلاعات زمان‌بندی یک نوع پیامک (با پیش‌فرض بی‌خطر برای انواع ناشناخته).""" + return TIMING.get(sms_type, {"kind": NONE, "field": None, "note": ""}) + + +def apply_timing(template, value): + """فیلد زمانیِ متناسب با نوع قالب را می‌نشاند و بقیه را صفر/خالی می‌کند. + + ``value`` عدد صحیحِ واردشده از فرم است (یا ``None``). این کار تضمین می‌کند صرف‌نظر از + اینکه فرم چه بفرستد، فقط یک فیلد زمانی برای هر قالب معنا داشته باشد. + """ + info = timing_for(template.sms_type) + field = info["field"] + template.offset_days = 0 + template.repeat_interval_days = None + template.repeat_interval_months = None + if field == "offset_days": + template.offset_days = value if value is not None else info["default"] + elif field == "repeat_interval_days": + template.repeat_interval_days = value if value else info["default"] + elif field == "repeat_interval_months": + template.repeat_interval_months = value if value else None + return template + + +def current_timing_value(template): + """مقدار فعلی فیلد زمانیِ متناسب (برای نمایش در فرم). + + اگر مقدار خالی باشد، پیش‌فرض همان نوع برگردانده می‌شود تا فیلدِ تنظیم‌نشده هم در + فرم دیده شود و مدیر بتواند مقدارش را تغییر دهد. + """ + info = timing_for(template.sms_type) + field = info["field"] + if field is None: + return None + value = getattr(template, field) + if value is None: + value = info.get("default") + return value diff --git a/admin_panel/templates/admin_panel/_customer_sms.html b/admin_panel/templates/admin_panel/_customer_sms.html new file mode 100644 index 0000000..1934bc7 --- /dev/null +++ b/admin_panel/templates/admin_panel/_customer_sms.html @@ -0,0 +1,70 @@ +{% if perms.admin_panel.ManageSMS %} +
+

پیامک

+ +
+
+

شماره‌های اصلی (گیرنده پیامک)

+ {# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند نمایش داده می‌شوند (جدول شماره‌ها یا فیلدهای قدیمی) #} + {% for entry in sms_phone_entries %} +
+ {% csrf_token %} + + +
+ +
+
+ {% if entry.is_mobile %} + + {% else %} + + {% endif %} +
+
+ {% empty %} +

شماره‌ای برای این مشتری ثبت نشده است.

+ {% endfor %} +

اگر شمارهٔ اصلی تعیین نشده باشد، شمارهٔ اولِ معتبر به‌صورت خودکار استفاده می‌شود.

+

گیرندگان فعلی: {% for r in sms_recipients %}{{ r }}{% if not forloop.last %}، {% endif %}{% empty %}—{% endfor %}

+
+ +
+

فعال/غیرفعال پیامک‌ها برای این مشتری

+ {% for item in sms_toggles %} +
+ {% csrf_token %} + + +
+ +
+
+ +
+
+ {% endfor %} +
+ +
+

ارسال پیامک سفارشی

+
+ {% csrf_token %} +
+
+ +
+
+

می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.

+ +
+
+
+
+{% endif %} diff --git a/admin_panel/templates/admin_panel/base.html b/admin_panel/templates/admin_panel/base.html index 54d1185..2c37cab 100644 --- a/admin_panel/templates/admin_panel/base.html +++ b/admin_panel/templates/admin_panel/base.html @@ -19,6 +19,7 @@ + {% block cssEnd %}{% endblock %} @@ -119,9 +120,26 @@ + {% if perms.admin_panel.ManageSMS %} + + {% endif %} + کاربران - + + {% if notice_text %} +
+
+
+ + {{ notice_text }} +
+
+
+ {% endif %} + {% block bodyPage %} {% endblock %} diff --git a/admin_panel/templates/admin_panel/customerBaseNew.html b/admin_panel/templates/admin_panel/customerBaseNew.html index 2a90950..0170dc6 100644 --- a/admin_panel/templates/admin_panel/customerBaseNew.html +++ b/admin_panel/templates/admin_panel/customerBaseNew.html @@ -45,86 +45,29 @@
- {% if customer.phones.all %} - {% for phone_obj in customer.phones.all %} -
-
- -
-
- -
- - {% if perms.admin_panel.ViewStore %} -
- -
- {% endif %} -
- {% endfor %} - - {% else %} - {% if customer.phone1 %} -
-
- -
-
- -
- - {% if perms.admin_panel.ViewStore %} -
- {% endif %} -
- {% endif %} - - {% if customer.phone2 %} -
-
- -
-
- -
- - {% if perms.admin_panel.ViewStore %} -
- {% endif %} -
- {% endif %} - - {% if customer.phone3 %} -
-
- -
-
- -
- - {% if perms.admin_panel.ViewStore %} -
- {% endif %} -
- {% endif %} - {% endif %} + {# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (جدول شماره‌ها + فیلدهای قدیمی)، بدون تکرار #} + {% for entry in sms_phone_entries %} +
+
+ +
+
+ +
+ + {% if perms.admin_panel.ViewStore %} +
+ +
+ {% endif %} +
+ {% empty %} +

شماره‌ای برای این مشتری ثبت نشده است.

+ {% endfor %}
{% if perms.admin_panel.ViewStore %}
+
+ +
+ +
+
+
@@ -686,7 +636,7 @@ $(document).ready(function() { var test = 0; $("#enEdit").click(function(){ - $("#formcsEdit").find("input").prop('disabled', false); + $("#formcsEdit").find("input, textarea").prop('disabled', false); // فعال کردن دکمه‌های کنترل تلفن $("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false); diff --git a/admin_panel/templates/admin_panel/customerNew2.html b/admin_panel/templates/admin_panel/customerNew2.html index 658236b..05273de 100644 --- a/admin_panel/templates/admin_panel/customerNew2.html +++ b/admin_panel/templates/admin_panel/customerNew2.html @@ -180,9 +180,11 @@
+ + {% include "./_customer_sms.html" %}
{% endblock %} - + {% block BodyCustomer2 %} -