fix mandehesab, bazdid, sms

This commit is contained in:
javad committed 2026-10-16 17:54:07 +03:30
1 parent 7e5c83bea2
commit 1fd27d0445
22 files changed
+16462 -5918

No files matched your search

@@ -0,0 +1,37 @@
# Generated by Django 3.2.25 on 2026-10-16 10:49
from django.db import migrations, models
class Migration(migrations.Migration):
"""افزودن ``choices`` به ``Action.actionType``.
این migration فقط وضعیت مدل را به‌روز می‌کند: Django ``choices`` را در
``deconstruct()`` فیلد نگه می‌دارد، پس بدون این migration دستور
``makemigrations --check`` شکست می‌خورد. تعریف ستون در SQLite هیچ تغییری
نمی‌کند (``choices`` در DDL بازتاب نمی‌یابد) و هیچ داده‌ای بازنویسی نمی‌شود.
"""
dependencies = [
('admin_panel', '0072_customernamefixjob'),
]
operations = [
migrations.AlterField(
model_name='action',
name='actionType',
field=models.CharField(
blank=True,
choices=[
('bazdid', 'بازدید'),
('nasb', 'نصب'),
('vosol', 'وصول مطالبات'),
('hamahangi', 'هماهنگی'),
('inCall', 'تماس ورودی'),
('outCall', 'تماس خروجی'),
],
max_length=50,
null=True,
),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 3.2.25 on 2026-10-16 10:49
from django.db import migrations, models
class Migration(migrations.Migration):
"""افزودن ``choices`` به ``Action.status``.
این migration فقط وضعیت مدل را به‌روز می‌کند: Django ``choices`` را در
``deconstruct()`` فیلد نگه می‌دارد، پس بدون این migration دستور
``makemigrations --check`` شکست می‌خورد. تعریف ستون در SQLite هیچ تغییری
نمی‌کند (``choices`` در DDL بازتاب نمی‌یابد) و هیچ داده‌ای بازنویسی نمی‌شود.
املای مقادیر عیناً همان چیزی است که در دیتابیس و کد موجود به‌کار رفته:
``'waite'`` (نه ``'wait'``)، ``'finish'`` و ``'cancell'``.
"""
dependencies = [
('admin_panel', '0073_alter_action_actiontype_choices'),
]
operations = [
migrations.AlterField(
model_name='action',
name='status',
field=models.CharField(
blank=True,
choices=[
('waite', 'در انتظار (باز)'),
('finish', 'انجام شده'),
('cancell', 'لغو شده'),
],
max_length=50,
null=True,
),
),
]
@@ -0,0 +1,49 @@
"""یادآوری تأمین موجودی چک: تغییر جهت از «پس از سررسید» به «پیش از سررسید».
هدف این migration **فقط** هم‌راستا‌کردن مقدار عددی ``offset_days`` قالب
``cheque_registration`` با معنای جدید است (تعداد روز **قبل** از سررسید).
آنچه این migration عمداً **نمی‌کند**:
* ``text`` (متن پیامک) را تغییر نمی‌دهد — متنی که مدیر در پنل تنظیم کرده دست‌نخورده
می‌ماند. اگر متن فعلی از زمان گذشته حرف می‌زند («سررسید شده است»)، مدیر می‌تواند
خودش از صفحهٔ «قالب‌های پیامک» ویرایشش کند.
* ``title``، ``is_enabled`` و ``auto_send`` را تغییر نمی‌دهد.
* هیچ نوع/قالب دیگری را لمس نمی‌کند.
مقدار ``offset_days`` روی پیش‌فرض معنای جدید (۱ روز قبل از سررسید) تنظیم می‌شود تا
نصب‌های تازه و نصب‌های موجود یکسان رفتار کنند؛ اما اگر مدیر قبلاً عددی غیر از مقدار
قدیمی (``1``) گذاشته باشد، به آن دست نمی‌زنیم تا انتخابش از بین نرود.
idempotent است؛ اجرای دوباره بی‌خطر است.
"""
from django.db import migrations
SMS_TYPE = "cheque_registration"
# مقدار پیش‌فرضِ منطق قدیم («۱ روز پس از سررسید») که با معنای جدید هم‌عددی است؛
# فقط رکوردهایی که هنوز روی همین مقدار هستند هم‌راستا می‌شوند.
OLD_DEFAULT = 1
NEW_DEFAULT = 1
def align_cheque_offset(apps, schema_editor):
"""``offset_days`` قالب چک را روی پیش‌فرض جدید بگذارد (بدون لمس متن)."""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.filter(
sms_type=SMS_TYPE, offset_days=OLD_DEFAULT,
).update(offset_days=NEW_DEFAULT)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0074_alter_action_status_choices"),
]
operations = [
migrations.RunPython(align_cheque_offset, noop),
]
@@ -0,0 +1,36 @@
"""افزودن متغیر ``{sayyad_id}`` به راهنمای ``help_text`` متن قالب پیامک.
هدف: راهنمای متغیرها در پنل، «شناسهٔ صیاد چک» را هم نشان دهد.
آنچه این migration عمداً **نمی‌کند**:
* ``text`` (متن واقعی قالب‌ها) را تغییر نمی‌دهد — فقط ``help_text`` فیلد عوض شده
است که در ``deconstruct()`` می‌آید و state مدل را جلو می‌برد.
* هیچ رکورد ``SMSTemplate`` را لمس نمی‌کند.
idempotent است؛ اجرای دوباره بی‌خطر است.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0075_cheque_funding_before_due"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="text",
field=models.TextField(
help_text=(
"متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، "
"{days} تعداد روز، {check_row} شماره ردیف چک، "
"{sayyad_id} شناسه صیاد چک، {owner_name} نام مالک چک."
),
verbose_name="متن پیامک",
),
),
]
@@ -0,0 +1,39 @@
"""افزودن متغیرهای ``{balance}`` / ``{balance_sep}`` به راهنمای متن قالب پیامک.
هدف: راهنمای متغیرها در پنل، «مانده حساب مشتری» را هم نشان دهد و تفاوتش را با
مبلغ سررسیدشده روشن کند.
آنچه این migration عمداً **نمی‌کند**:
* ``text`` (متن واقعی قالب‌ها) را تغییر نمی‌دهد — فقط ``help_text`` فیلد عوض شده
است که در ``deconstruct()`` می‌آید و state مدل را جلو می‌برد.
* هیچ رکورد ``SMSTemplate`` را لمس نمی‌کند.
idempotent است؛ اجرای دوباره بی‌خطر است.
"""
from django.db import migrations, models
HELP_TEXT = (
"متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
"{check_row} شماره ردیف چک، {sayyad_id} شناسه صیاد چک، "
"{owner_name} نام مالک چک، {balance} مانده حساب مشتری، "
"{balance_sep} مانده حساب با جداکننده. "
"(توجه: {balance} مانده کل حساب است و با مبلغ سررسیدشدهٔ چک/نسیه "
"که در {amount} می‌آید تفاوت دارد.)"
)
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0076_alter_smstemplate_text_help"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="text",
field=models.TextField(help_text=HELP_TEXT, verbose_name="متن پیامک"),
),
]
+32 -2
View File
@@ -22,7 +22,35 @@ class VisitQueue(models.Model):
class Action(models.Model):
actionType = models.CharField(max_length=50, null=True, blank=True)
"""اقدام/وظیفهٔ CRM مرتبط با مشتری.
``actionType`` فقط این شش مقدار را می‌پذیرد؛ همین شش مقدار در فرم‌های پنل
(``customerActionInc.html``) و در تمام مقایسه‌های کد (``== 'bazdid'``،
``exclude(actionType='vosol')`` و …) استفاده می‌شوند.
``status`` نیز همان سه مقدارِ به‌کاررفته در کد و قالب‌ها را می‌پذیرد.
املای ``'waite'`` عمداً حفظ شده است: ۲٫۷۱۹ رکورد موجودِ دیتابیس و تمام
مقایسه‌های کد/قالب (``== 'waite'``، ``if filter_status == 'waite'``) با
همین املا کار می‌کنند و تغییرش داده را از موتور جست‌وجو جدا می‌کند.
"""
class ActionType(models.TextChoices):
BAZDID = 'bazdid', 'بازدید'
NASB = 'nasb', 'نصب'
VOSOL = 'vosol', 'وصول مطالبات'
HAMAHANGI = 'hamahangi', 'هماهنگی'
IN_CALL = 'inCall', 'تماس ورودی'
OUT_CALL = 'outCall', 'تماس خروجی'
class Status(models.TextChoices):
# املا عیناً مطابق داده و کد فعلی است (نه 'wait').
WAITE = 'waite', 'در انتظار (باز)'
FINISH = 'finish', 'انجام شده'
CANCELL = 'cancell', 'لغو شده'
actionType = models.CharField(
max_length=50, null=True, blank=True, choices=ActionType.choices,
)
forUser = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
customer = models.ForeignKey('Customer', related_name='actionrlt', on_delete=models.CASCADE, null=True, blank=True)
createdDT = models.DateTimeField(auto_now_add=True)
@@ -31,7 +59,9 @@ class Action(models.Model):
finishDateTime = models.DateTimeField(null=True, blank=True)
desc = models.CharField(max_length=500, null=True, blank=True)
result = models.CharField(max_length=500, null=True, blank=True)
status = models.CharField(max_length=50, null=True, blank=True)
status = models.CharField(
max_length=50, null=True, blank=True, choices=Status.choices,
)
nazar = models.CharField(max_length=50, null=True, blank=True, default=None)
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
+5 -1
View File
@@ -154,7 +154,11 @@ class SMSTemplate(models.Model):
verbose_name="متن پیامک",
help_text="متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
"{check_row} شماره ردیف چک، {owner_name} نام مالک چک.",
"{check_row} شماره ردیف چک، {sayyad_id} شناسه صیاد چک، "
"{owner_name} نام مالک چک، {balance} مانده حساب مشتری، "
"{balance_sep} مانده حساب با جداکننده. "
"(توجه: {balance} مانده کل حساب است و با مبلغ سررسیدشدهٔ چک/نسیه "
"که در {amount} می‌آید تفاوت دارد.)",
)
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
auto_send = models.BooleanField(
+44
View File
@@ -0,0 +1,44 @@
"""مانده حساب مشتری برای استفاده در قالب‌های پیامک.
تعریف (همان قاعدهٔ صفحهٔ حسابداری مشتری و گزارش مالی)::
مانده حساب = مجموع بدهکار (debit) − مجموع بستانکار (credit)
نکتهٔ مهم — تفاوت با «مانده سررسیدشده»:
* **مانده حساب** (این ماژول): تراز کل همهٔ اسناد حسابداری مشتری (``Article``).
منبع: ``admin_panel/models/finance.py::Article``.
* **مانده/مبلغ سررسیدشده**: مبلغ چک‌ها و نسیه‌های پرداخت‌نشده در جدول
``Sarresid`` (``payStatus='false'``). بسیار متفاوت از مانده حساب است و
جای دیگری (``views/finance.py``) محاسبه می‌شود.
به همین دلیل این مقدار با کلید مستقل ``{balance}`` در قالب‌ها منتشر می‌شود و
هرگز با مبلغ سررسید (``{amount}`` در پیامک چک/نسیه) قاطی نمی‌شود.
"""
import logging
logger = logging.getLogger(__name__)
def customer_balance(customer):
"""مانده حساب مشتری (``debit − credit``) به‌صورت عدد صحیح.
اگر مشتری یا اسنادش وجود نداشته باشد ``0`` برمی‌گردد. هیچ استثنایی پرتاب
نمی‌کند تا مسیر ساخت پیامک هرگز مختل نشود.
"""
if customer is None or getattr(customer, "pk", None) is None:
return 0
try:
from django.db.models import Sum
from ..models import Article
agg = Article.objects.filter(customer=customer).aggregate(
d=Sum("debit"), c=Sum("credit"),
)
debit = agg["d"] or 0
credit = agg["c"] or 0
return debit - credit
except Exception:
logger.exception("محاسبهٔ مانده حساب مشتری %s شکست خورد", customer.pk)
return 0
+10 -1
View File
@@ -5,6 +5,7 @@ from uuid import uuid4
from django.db import IntegrityError
from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType
from .balance import customer_balance
from .recipients import resolve_recipients
from .render import render_template
from .rules import is_sms_enabled
@@ -22,6 +23,11 @@ def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=None,
یا آزمایشی)، و ``SMSTemplate.auto_send`` که مدیر در صفحهٔ قالب‌ها برای هر نوع
پیامک روشن می‌کند تا پیامک‌های خودکارِ اسکنر بدون صف تأیید ارسال شوند.
متغیرهای مشترکِ همهٔ قالب‌ها (مثل ``{balance}`` = مانده حساب مشتری) همین‌جا و
یک‌بار به ``context`` افزوده می‌شوند تا هر قالبی بتواند از آن‌ها استفاده کند؛
فراخوان‌ها لازم نیست هر بار آن‌ها را بسازند. اگر فراخوان خودش مقداری داده باشد
(``context``) همان اولویت دارد.
هیچ استثنایی به فراخوان پرتاب نمی‌شود؛ خطاها فقط لاگ می‌شوند تا مسیر اصلی
(مثلاً ایجاد مشتری یا اسکنر) هرگز به‌خاطر پیامک مختل نشود.
"""
@@ -30,7 +36,10 @@ def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=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)
ctx = dict(context or {})
# متغیرهای سراسری قالب‌ها؛ هرگز مقدار فراخوان را بازنویسی نمی‌کنند.
ctx.setdefault("balance", customer_balance(customer))
message = render_template(template.text, ctx)
# «ارسال خودکار» هر نوع پیامک در صفحهٔ قالب‌ها تعیین می‌شود؛ خاموش = بازبینی مدیر.
approved = approved or template.auto_send
+15 -1
View File
@@ -3,11 +3,21 @@ from persiantools.jdatetime import JalaliDate, JalaliDateTime
class _SafeDict(dict):
"""دیکشنری که برای کلیدهای ناشناخته رشته خالی برمی‌گرداند (بدون خطا)."""
"""دیکشنری امن برای ``str.format_map``.
* کلیدِ ناشناخته ⇒ رشتهٔ خالی (بدون ``KeyError``).
* کلیدِ شناخته‌شده با مقدار ``None`` ⇒ رشتهٔ خالی. فیلدهای اختیاری مدل
(مثل ``Sarresid.check_sayyad_id`` یا ``check_row_number``) ممکن است ``NULL``
باشند؛ بدون این تبدیل، ``None`` عیناً در متن پیامک چاپ می‌شد.
"""
def __missing__(self, key):
return ""
def __getitem__(self, key):
value = super().__getitem__(key) if key in self else ""
return "" if value is None else value
def format_amount(value):
try:
@@ -35,6 +45,10 @@ def render_template(text, context=None):
ctx = dict(context or {})
if "amount" in ctx:
ctx.setdefault("amount_sep", format_amount(ctx["amount"]))
if "balance" in ctx:
# مثل «مبلغ»، نسخهٔ جداکننده‌دار هم آماده می‌شود تا در قالب‌ها
# ``{balance_sep}`` در دسترس باشد (۳٬۵۰۰٬۰۰۰ → "3,500,000").
ctx.setdefault("balance_sep", format_amount(ctx["balance"]))
if "date" in ctx:
ctx.setdefault("date_raw", ctx["date"])
ctx["date"] = format_jalali(ctx["date"])
+23 -10
View File
@@ -81,36 +81,49 @@ class _Budget:
def scan_cheque_funding(today):
"""یادآوری «تأمین موجودی چک» چند روز پس از سررسید چکِ پرداخت‌نشده.
"""یادآوری «تأمین موجودی چک» چند روز **پیش از** سررسید چکِ پرداخت‌نشده.
چند روزِ پس از سررسید از قالب همین نوع (قابل تنظیم از پنل) خوانده می‌شود و مبلغ
چک در متن قرار می‌گیرد. اگر چک پرداخت شود (``payStatus=true``) دیگر یادآوری
نمی‌گیرد؛ ``dedup_key`` نیز مانع ارسال تکراری در اجراهای بعدی می‌شود. اگر اسکنر
چند روز از دست برود، چک‌های «معوق‌تر» هم واجد شرط می‌شوند تا یادآوری از دست نرود.
تعداد روزِ *قبل* از سررسید از قالب همین نوع (``offset_days`` در دیتابیس،
قابل ویرایش از پنل) خوانده می‌شود؛ مثلاً ``offset_days = 1`` یعنی یک روز پیش
از سررسید پیامک می‌رود تا مشتری فرصت تأمین موجودی داشته باشد.
پنجرهٔ یادآوری از «سررسید − offset روز» باز می‌شود و تا خودِ سررسید ادامه
دارد؛ پس اگر اسکنر چند روز از دست برود، یادآوری از دست نمی‌رود (چک‌هایی که
سررسیدشان رسیده هم واجد شرط می‌مانند). اگر چک پرداخت شود
(``payStatus=true``) دیگر یادآوری نمی‌گیرد؛ ``dedup_key`` نیز مانع ارسال
تکراری در اجراهای بعدی می‌شود.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
# پنجرهٔ یادآوری: از «سررسید − offset» تا «سررسید». یعنی فقط سررسیدهای
# امروز تا offset روز آینده واجد شرط‌اند و مقادیر دورتر هنوز پیامکی نمی‌گیرند.
# (سررسیدهای گذشته هم داخل شرط می‌مانند تا یک یادآوری از دست نرود.)
window_end = today + timedelta(days=max(offset, 0))
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__lte=target,
type="check", payStatus="false", sarresidDT__date__lte=window_end,
).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)
due = s.sarresidDT.date()
# لحظهٔ باز شدن پنجرهٔ یادآوری = سررسید − offset روز.
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (today - s.sarresidDT.date()).days
# روزهای باقی‌مانده تا سررسید؛ اگر سررسید دورتر از پنجره باشد پیامک نمی‌رود.
days = (due - today).days
if days > offset:
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": days, "check_row": s.check_row_number,
"sayyad_id": s.check_sayyad_id, "owner_name": s.check_owner_name,
},
dedup_key=f"cheque_fund:{s.id}",
)
+3 -3
View File
@@ -27,11 +27,11 @@ TIMING = {
"note": "بی‌درنگ پس از ثبت فاکتور ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": AFTER_DUE,
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز پس از سررسید چک",
"label": "چند روز قبل از سررسید چک",
"default": 1,
"note": "این تعداد روز پس از سررسید چکِ پرداخت‌نشده، پیامک یادآوری "
"note": "این تعداد روز پیش از سررسید چکِ پرداخت‌نشده، پیامک یادآوری "
"تأمین موجودی با مبلغ چک ارسال می‌شود.",
},
SMSType.VISIT: {
+5 -5
View File
@@ -12,6 +12,7 @@ from persiantools.jdatetime import JalaliDate
from ..models import (Customer, PhoneNumber, ProductCategory, Product, Factor,
FactorIteam, Article, Sarresid, Action, CacheTable)
from ..sms.balance import customer_balance
from ..sms.phones import (normalize_primary_values,
save_primary_normalized)
from ..sms.triggers import notify_payment_thanks, notify_welcome
@@ -437,14 +438,13 @@ def customerDel(request, c_id):
@login_required(login_url='/panel/p_login/')
def customerHesabdari(request ,c_id):
customer = Customer.objects.get(id=c_id)
# مانده حساب از همان تابع مشترکی می‌آید که قالب‌های پیامک استفاده می‌کنند تا
# عددِ این صفحه و پیامک هرگز واگرا نشوند (منبع حقیقت: sms/balance.py).
mandehesabCs = customer_balance(customer)
try:
debit = Article.objects.filter(customer=customer).aggregate(Sum('debit'))['debit__sum']
credit = Article.objects.filter(customer=customer).aggregate(Sum('credit'))['credit__sum']
mandehesabCs = debit - credit
articls = Article.objects.filter(customer=customer).order_by('-createdDT')
except:
articls =None
mandehesabCs = 0
articls = None
dateNow = datetime.datetime.now().date
data = {'customer': customer, 'mandehesabCs': mandehesabCs, 'articls': articls}
+7 -1
View File
@@ -64,14 +64,17 @@ TEMPLATE_VARIABLES = [
("{date}", "تاریخ جلالی"),
("{days}", "تعداد روز"),
("{check_row}", "شمارهٔ ردیف چک"),
("{sayyad_id}", "شناسهٔ چک صیاد (۱۶ رقم)"),
("{owner_name}", "نام مالک چک"),
("{balance}", "مانده حساب مشتری (بدهکار − بستانکار)"),
("{balance_sep}", "مانده حساب با جداکنندهٔ هزارگان"),
]
# فقط برای نمایش: آیکن و توضیح کوتاه «چه زمانی ارسال می‌شود» هر نوع (لایهٔ UI، نه دامنه).
SMS_TYPE_UI = {
SMSType.WELCOME: {"icon": "👋", "when": "بی‌درنگ پس از ثبت مشتری"},
SMSType.SURVEY: {"icon": "⭐", "when": "بی‌درنگ پس از ثبت فاکتور"},
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پس از سررسید چک"},
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
@@ -86,7 +89,10 @@ SAMPLE_CONTEXT = {
"date": None, # در زمان ساخت ویو با تاریخ امروز پر می‌شود
"days": 2,
"check_row": "1234",
"sayyad_id": "1234567890123456",
"owner_name": "نام مالک",
"balance": 3500000,
"balance_sep": "3,500,000",
}