improve entezar bazdid

This commit is contained in:
javad committed 2026-10-20 19:02:34 +03:30
1 parent 1fd27d0445
commit 2aee85593f
18 files changed
+10271 -7558

No files matched your search

@@ -0,0 +1,81 @@
"""پاک‌سازی کلیدهای «آلوده» دفتر یکتاسازی یادآوری بازدید.
چرا لازم است؟ کلید یکتاسازیِ یادآوری بازدید، در نسخهٔ قدیمی ``visit:<id>:<شمارهٔ چرخه>``
بود که شمارهٔ چرخه با تقسیم روز بر یک عدد ثابت محاسبه می‌شد. وقتی آن عدد صفر می‌شد،
عددهای بی‌معنا (مثل ``visit:1601:24663``) ساخته و برای همیشه در ``SMSDedup`` ثبت
می‌شدند؛ نتیجه‌اش قفل دائمی یادآوری برای همان مشتری بود.
قاعدهٔ جدید کلید را با «موعد تقویمی» می‌سازد: ``visit:<id>:<YYYY-MM>``. کلیدهای قدیمی
هرگز با کلید جدید برخورد نمی‌کنند، بنابراین بی‌ضرر ولی بازدارنده‌اند.
این دستور **فقط** کلیدهای بی‌معنا/قدیمیِ دفتر را حذف می‌کند و هیچ مشتری، اقدام یا
پیامکی را تغییر نمی‌دهد.
python manage.py cleanup_visit_dedup --dry-run # فقط گزارش
python manage.py cleanup_visit_dedup # حذف کلیدهای بی‌معنا (cycle > حد)
python manage.py cleanup_visit_dedup --all-legacy # حذف همهٔ کلیدهای قالب قدیمی
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from ...models import SMSDedup
# سقف منطقی شمارهٔ چرخه؛ هر عدد بزرگ‌تر از این، نتیجهٔ باگ تقسیم قدیمی است.
MAX_SANE_CYCLE = 50
LEGACY_PATTERN = re.compile(r"^visit:(\d+):(\d+)$")
NEW_PATTERN = re.compile(r"^visit:(\d+):(\d{4}-\d{2})$")
class Command(BaseCommand):
help = "حذف کلیدهای آلوده/قدیمی دفتر یکتاسازی یادآوری بازدید (بدون تغییر دادهٔ مشتری)."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true",
help="فقط گزارش؛ چیزی حذف نمی‌شود.")
parser.add_argument("--all-legacy", action="store_true",
help="همهٔ کلیدهای قالب قدیمی (visit:<id>:<عدد>) حذف شوند، "
"نه فقط عددهای بی‌معنا.")
def handle(self, *args, **options):
dry_run = options["dry_run"]
all_legacy = options["all_legacy"]
absurd, sane_legacy, current = [], [], []
for key in SMSDedup.objects.filter(key__startswith="visit:").values_list(
"key", flat=True
).iterator():
if NEW_PATTERN.match(key):
current.append(key)
continue
match = LEGACY_PATTERN.match(key)
if match:
cycle = int(match.group(2))
(absurd if cycle > MAX_SANE_CYCLE else sane_legacy).append(key)
else:
absurd.append(key)
doomed = absurd + (sane_legacy if all_legacy else [])
self.stdout.write(self.style.MIGRATE_LABEL("گزارش کلیدهای یادآوری بازدید:"))
self.stdout.write(f" کلیدهای قالب جدید (موعد تقویمی): {len(current):,}")
self.stdout.write(f" کلیدهای بی‌معنا (چرخه > {MAX_SANE_CYCLE} یا ناشناخته): {len(absurd):,}")
self.stdout.write(f" کلیدهای قدیمیِ سالم (چرخه ≤ {MAX_SANE_CYCLE}): {len(sane_legacy):,}")
if absurd:
self.stdout.write(" نمونهٔ کلیدهای بی‌معنا: " + ", ".join(absurd[:5]))
self.stdout.write(f" قابل حذف در این اجرا: {len(doomed):,}")
if dry_run:
self.stdout.write(self.style.WARNING(
"\n[شبیه‌سازی] هیچ کلیدی حذف نشد."
))
return
if not doomed:
self.stdout.write(self.style.SUCCESS("\nانجام شد: چیزی برای حذف نبود."))
return
with transaction.atomic():
deleted, _ = SMSDedup.objects.filter(key__in=doomed).delete()
self.stdout.write(self.style.SUCCESS(
f"\nانجام شد: {deleted:,} کلید حذف شد (فقط از دفتر یکتاسازی)."
))
@@ -1,156 +1,270 @@
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی اقدام‌های «بازدیدِ تمام‌شده».
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی منبع حقیقت «آخرین بازدید».
فیلد ``last_bazdid_dt`` «آخرین بازدید» مشتری است و صفحهٔ ``/panel/entezar/`` بر پایهٔ
آن مشتریانِ معوق (بیش از N ماه بازدید نشده) را لیست می‌کند. این مقدار باید برابر
«بزرگ‌ترین ``finishDateTime`` میان اقدام‌های ``actionType='bazdid'`` و ``status='finish'``
هر مشتری» باشد — همان چیزی که ویوهای ``customerNewAction``/``customerEditAction``
می‌نویسند.
منبع حقیقت، ``admin_panel.visit_policy.last_visit_map`` است: بزرگ‌ترین تاریخ مؤثر
در میان اقدام‌های ``bazdid + finish`` هر مشتری (تاریخ مؤثر = ``actionDateTime`` اگر
از حالا جلوتر نباشد، وگرنه ``finishDateTime``).
اگر رکوردی از راهی غیر از این ویوها وارد شود (اسکریپت، ایمپورت، ویرایش دستی دیتابیس)،
این فیلد عقب می‌ماند و مشتریِ واقعاً بازدیدشده اشتباهاً «معوق» شمرده می‌شود. این
دستور آن واگرایی (drift) را از منبع حقیقت (جدول Action) ترمیم می‌کند.
این دستور **فقط** ستون ``last_bazdid_dt`` مشتری را عوض می‌کند و هیچ رکورد اقدامی
(از جمله رکوردهای «هماهنگی») را نمی‌خواند/نمی‌نویسد.
python manage.py recalc_last_bazdid --dry-run # فقط گزارش، بدون هیچ تغییری
python manage.py recalc_last_bazdid # اعمال اصلاحات
python manage.py recalc_last_bazdid --dry-run # فقط گزارش
python manage.py recalc_last_bazdid # فقط اصلاح رو به جلو
python manage.py recalc_last_bazdid --exact --confirm=YES # ترمیم کامل + بکاپ خودکار
مشخصات:
* idempotent — اجرای دوباره پس از یک‌بار اجرا هیچ تغییری ایجاد نمی‌کند.
* پیش‌فرض محافظه‌کارانه است: فقط مقدار را **به‌روز** می‌کند و هیچ‌وقت روی مقادیری که
اقدامِ پشتیبان ندارند دست نمی‌گذارد (مگر با ``--clear-orphans``).
* روی دیتابیس production هم قابل اجرا است.
* ``--dry-run``: هیچ نوشتنی انجام نمی‌شود.
* ``--exact``: هر اختلافی (رو به جلو، رو به عقب، پرکردن مقدار خالی) اصلاح می‌شود.
بدون ``--confirm=YES`` اجرا نمی‌شود و پیش از هر نوشتن، خودکار ``VACUUM INTO``
می‌گیرد؛ اگر بکاپ شکست بخورد، هیچ چیز نوشته نمی‌شود.
* ``--clear-orphans``: مقادیری که هیچ بازدید معتبری پشتشان نیست را ``NULL`` می‌کند.
* ``--batch-size``: اندازهٔ دستهٔ خواندن مشتریان.
idempotent است: اجرای دوباره در همان حالت هیچ تغییری ایجاد نمی‌کند.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Max
import os
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from ...models import Action, Customer
from ...visit_policy import as_aware, last_visit_map
CONFIRM_TOKEN = "YES"
SAMPLE_LIMIT = 20
def _bucket(current, new, clear_orphans):
"""دستهٔ تغییر پیشنهادی برای یک مشتری."""
if new is None:
if current is not None and clear_orphans:
return "orphan_cleared"
return "no_change"
if current is None:
return "fill_null"
if new > current:
return "forward"
if new < current:
return "backward"
return "no_change"
class Command(BaseCommand):
help = (
"بازمحاسبهٔ Customer.last_bazdid_dt از MAX(finishDateTime) اقدام‌های "
"بازدیدِ تمام‌شده (ترمیم واگرایی داده؛ idempotent)."
"بازمحاسبهٔ Customer.last_bazdid_dt از منبع حقیقت بازدید "
"(فقط bazdid + finish؛ هرگز هماهنگی)."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ هیچ رکوردی ذخیره نمی‌شود.",
help="فقط گزارش؛ هیچ رکوردی ذخیره نمی‌شود.",
)
parser.add_argument(
"--exact", action="store_true",
help="هر اختلافی را اصلاح کن (شامل عقب‌بردن مقدار). نیازمند --confirm=YES.",
)
parser.add_argument(
"--confirm", default="",
help="برای اجرای --exact باید مقدار YES باشد.",
)
parser.add_argument(
"--clear-orphans", action="store_true",
help="مقدارهایی که هیچ اقدامِ بازدیدِ تمام‌شده‌ای پشتشان نیست را هم NULL کن "
"(پیش‌فرض: دست‌نخورده بمانند). فقط اگر مطمئنی این ستون آلوده است.",
help="مقدارهایی که هیچ اقدام بازدید معتبری پشتشان نیست را NULL کن.",
)
parser.add_argument(
"--batch-size", type=int, default=500,
help="اندازهٔ دستهٔ خواندن مشتریان در هر گام (پیش‌فرض ۵۰۰).",
help="اندازهٔ دستهٔ خواندن مشتریان (پیش‌فرض ۵۰۰).",
)
# ------------------------------------------------------------------
def handle(self, *args, **options):
dry_run = options["dry_run"]
exact = options["exact"]
clear_orphans = options["clear_orphans"]
batch_size = max(1, options["batch_size"])
if exact and not dry_run:
if options["confirm"].strip().upper() != CONFIRM_TOKEN:
raise CommandError(
"اجرای --exact بدون تاییدیه مجاز نیست. ابتدا --dry-run --exact "
"را ببینید، از دیتابیس بکاپ بگیرید و سپس با --confirm=YES اجرا کنید."
)
prefix = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(self.style.MIGRATE_HEADING(
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
))
# منبع حقیقت: بزرگ‌ترین زمانِ پایانِ بازدید در اقدام‌های تمام‌شدهٔ هر مشتری.
# (Max مقادیر NULL را نادیده می‌گیرد؛ مشتریِ بدون بازدید به‌کل در نقشه نمی‌آید.)
latest_rows = (
Action.objects
.filter(actionType="bazdid", status="finish")
.exclude(customer_id__isnull=True)
.values("customer_id")
.annotate(mx=Max("finishDateTime"))
)
latest = {
row["customer_id"]: row["mx"]
for row in latest_rows.iterator(chunk_size=batch_size)
if row["mx"] is not None
}
stats, samples, pending, suspects = self._plan(exact, clear_orphans, batch_size)
stats = {
"scanned": 0, # کل مشتریان بررسی‌شده
"with_visit": 0, # مشتریان دارای بازدیدِ تمام‌شده
"updated": 0, # مقدار اصلاح/به‌روز شد
"moved_forward": 0, # جدیدتر شد ⇒ از لیست معوق‌ها خارج می‌شود
"moved_backward": 0, # عقب‌تر شد
"set_null": 0, # با --clear-orphans خالی شد
"already_ok": 0, # از قبل درست بود
"kept_orphan": 0, # مقدار داشت ولی اقدامِ پشتیبان نداشت (دست‌نخورده ماند)
"null_and_no_action": 0, # NULL و بدون اقدام (طبیعی؛ کاری لازم نیست)
}
pending = [] # (customer_id, target_value) برای اعمال گروهی
qs = Customer.objects.only("id", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
mx = latest.get(customer.id)
current = customer.last_bazdid_dt
if mx is not None:
stats["with_visit"] += 1
if current == mx:
stats["already_ok"] += 1
continue
if current is None or current < mx:
stats["moved_forward"] += 1
else:
stats["moved_backward"] += 1
stats["updated"] += 1
pending.append((customer.id, mx))
else:
# هیچ بازدیدِ تمام‌شده‌ای پشتوانهٔ این مقدار نیست.
if current is None:
stats["null_and_no_action"] += 1
elif clear_orphans:
stats["set_null"] += 1
pending.append((customer.id, None))
else:
stats["kept_orphan"] += 1
backup_path = None
if exact and not dry_run and pending:
backup_path = self._backup()
self.stdout.write(self.style.SUCCESS(f"بکاپ ساخته شد: {backup_path}"))
if not dry_run and pending:
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(
last_bazdid_dt=target
)
self._apply(pending)
self._report(stats, dry_run, clear_orphans)
self._report(stats, samples, suspects, dry_run, exact, clear_orphans,
len(pending), backup_path)
def _report(self, stats, dry_run, clear_orphans):
# ------------------------------------------------------------------
def _plan(self, exact, clear_orphans, batch_size):
"""محاسبهٔ تغییرات پیشنهادی بدون هیچ نوشتنی."""
visits = last_visit_map()
stats = {
"scanned": 0, "with_visit": 0, "no_change": 0,
"fill_null": 0, "forward": 0, "backward": 0, "orphan_cleared": 0,
"kept_orphan": 0,
}
pending = []
samples = []
qs = Customer.objects.only("id", "name_family", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
new = visits.get(customer.id)
current = as_aware(customer.last_bazdid_dt)
if new is not None:
stats["with_visit"] += 1
bucket = _bucket(current, new, clear_orphans)
if bucket == "no_change":
stats["no_change"] += 1
if new is None and current is not None:
stats["kept_orphan"] += 1
continue
if not exact and bucket in ("backward", "orphan_cleared"):
stats["no_change"] += 1 # حالت پیش‌فرض فقط رو به جلو حرکت می‌کند
continue
stats[bucket] += 1
pending.append((customer.id, new))
if len(samples) < SAMPLE_LIMIT:
samples.append((customer.id, customer.name_family, current, new, bucket))
return stats, samples, pending, self._hamahangi_suspect_count()
def _apply(self, pending):
"""اعمال تغییرات؛ فقط ستون ``last_bazdid_dt`` مشتری نوشته می‌شود."""
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(last_bazdid_dt=target)
# ------------------------------------------------------------------
def _backup(self):
"""یک نسخهٔ سالم از دیتابیس با timestamp می‌سازد؛ خطا یعنی توقف کامل اجرا.
اول ``VACUUM INTO`` (سریع و اتمی) و اگر SQLite اجازه نداد (مثلاً داخل یک
تراکنش باز)، نسخهٔ سازگار با روش رسمی ``sqlite3.Connection.backup``
گرفته می‌شود تا در هر دو حالت یک فایل سالم حاصل شود.
"""
db_path = str(connection.settings_dict.get("NAME") or "")
if not db_path:
raise CommandError("مسیر دیتابیس مشخص نیست؛ بکاپ ممکن نشد.")
target_dir = os.path.dirname(os.path.abspath(db_path))
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(target_dir, f"backup-{stamp}.sqlite3")
try:
with connection.cursor() as cursor:
cursor.execute("VACUUM INTO %s", [backup_path])
except Exception as vacuum_error: # noqa: BLE001
try:
self._copy_database(db_path, backup_path)
except Exception as copy_error: # noqa: BLE001
raise CommandError(
"بکاپ گرفتن شکست خورد؛ هیچ تغییری اعمال نشد: "
f"{vacuum_error} / {copy_error}"
)
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
raise CommandError("فایل بکاپ ساخته نشد یا خالی است؛ اجرا متوقف شد.")
return backup_path
def _copy_database(self, source_path, target_path):
"""نسخهٔ پشتیبان سازگار از فایل SQLite (روش رسمی ``backup``)."""
import sqlite3
source = sqlite3.connect(self._source_uri(source_path), uri=True)
try:
target = sqlite3.connect(target_path)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
@staticmethod
def _source_uri(source_path):
"""URI فقط‌خواندنی؛ اگر مسیر خودش URI باشد (دیتابیس تست) دست‌نخورده می‌ماند."""
if source_path.startswith("file:"):
return source_path
return f"file:{source_path}?mode=ro"
def _hamahangi_suspect_count(self):
"""تعداد مشتریانی که مقدارشان دقیقاً برابر تاریخ یک رکورد هماهنگی است.
تشخیص **تقریبی** است: ``last_bazdid_dt`` هیچ ارجاعی به رکورد منبع ندارد،
پس برابری مقدار فقط «نشانه» است، نه اثبات.
"""
stamps = {}
rows = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
).values_list("customer_id", "actionDateTime", "finishDateTime")
for customer_id, action_dt, finish_dt in rows.iterator():
stamps.setdefault(customer_id, set()).update(
v for v in (as_aware(action_dt), as_aware(finish_dt)) if v is not None
)
if not stamps:
return 0
count = 0
qs = Customer.objects.filter(id__in=list(stamps)).only("id", "last_bazdid_dt")
for customer in qs.iterator():
if as_aware(customer.last_bazdid_dt) in stamps[customer.id]:
count += 1
return count
def _report(self, stats, samples, suspects, dry_run, exact, clear_orphans,
changed, backup_path=None):
style = self.style
label = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(style.MIGRATE_LABEL(f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید:"))
mode = "exact" if exact else "default(forward-only)"
self.stdout.write(style.MIGRATE_LABEL(
f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید (حالت: {mode}):"
))
self.stdout.write(f" بررسی‌شده (کل مشتریان): {stats['scanned']:,}")
self.stdout.write(f" دارای بازدیدِ تمام‌شده: {stats['with_visit']:,}")
self.stdout.write(f" از قبل درست: {stats['already_ok']:,}")
self.stdout.write(f" نیازمند اصلاح: {stats['updated']:,}")
self.stdout.write(f" → جدیدتر شد (خروج از معوق): {stats['moved_forward']:,}")
self.stdout.write(f" → عقب‌تر شد: {stats['moved_backward']:,}")
self.stdout.write(f" دارای بازدید معتبر: {stats['with_visit']:,}")
self.stdout.write(f" بدون تغییر: {stats['no_change']:,}")
self.stdout.write(f" پر کردن مقدار خالی: {stats['fill_null']:,}")
self.stdout.write(f" اصلاح رو به جلو: {stats['forward']:,}")
self.stdout.write(f" اصلاح رو به عقب: {stats['backward']:,}")
if clear_orphans:
self.stdout.write(f" → خالی شد (بدون اقدام): {stats['set_null']:,}")
self.stdout.write(f" پاک‌سازی یتیم: {stats['orphan_cleared']:,}")
else:
self.stdout.write(
f" بدون اقدام ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
f" بدون بازدید ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
)
self.stdout.write(
f" NULL و بدون اقدام (طبیعی): {stats['null_and_no_action']:,}"
)
self.stdout.write(style.WARNING(
f" نشانهٔ آلودگی هماهنگی (تقریبی): {suspects:,}"
))
if samples:
self.stdout.write("\n نمونهٔ تغییرات (id | مشتری | فعلی → جدید | دسته):")
for customer_id, name, current, new, bucket in samples:
self.stdout.write(f" {customer_id} | {name} | {current} → {new} | {bucket}")
if dry_run:
self.stdout.write(style.WARNING(
"\n[حالت شبیه‌سازی] هیچ رکوردی ذخیره نشد. برای اعمال، بدون --dry-run اجرا کن."
f"\n[شبیه‌سازی] {changed:,} تغییر پیشنهادی. هیچ رکوردی ذخیره نشد."
))
elif stats["updated"]:
elif changed:
self.stdout.write(style.SUCCESS(
f"\nانجام شد: {stats['updated']:,} رکورد به‌روزرسانی شد."
f"\nانجام شد: {changed:,} مشتری به‌روزرسانی شد (فقط ستون last_bazdid_dt)."
))
else:
self.stdout.write(style.SUCCESS(
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)."
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل هم‌راستا است)."
))
if backup_path:
self.stdout.write(f" بکاپ: {backup_path}")
@@ -0,0 +1,36 @@
# Generated by Django 3.2.25 on 2026-10-20 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0077_alter_smstemplate_text_balance_help'),
]
operations = [
# ایندکس سه‌ستونیِ قبلی باید ابتدا برداشته شود؛ وگرنه نام ``act_visit_idx``
# تکراری می‌شود. ساخت ایندکس پوشا هم همین‌جا انجام می‌شود تا وضعیت مدل
# (``Meta.indexes``) و دیتابیس دقیقاً یکی باشند.
migrations.RunSQL(
sql=[
'DROP INDEX IF EXISTS "act_visit_idx";',
(
'CREATE INDEX "act_visit_idx" ON "admin_panel_action" '
'("actionType", "status", "customer_id", "actionDateTime", "finishDateTime");'
),
],
reverse_sql=['DROP INDEX IF EXISTS "act_visit_idx";'],
state_operations=[
migrations.AddIndex(
model_name='action',
index=models.Index(
fields=['actionType', 'status', 'customer_id',
'actionDateTime', 'finishDateTime'],
name='act_visit_idx',
),
),
],
),
]
+16
View File
@@ -66,3 +66,19 @@ class Action(models.Model):
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
class Meta:
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
# نمی‌کند. اندازه‌گیری روی دادهٔ واقعی: ۱۶۴ms → ۲۹ms.
# ستون‌های actionType/status/customer_id برای فیلتر و group، و دو ستون
# تاریخ برای محاسبهٔ «تاریخ مؤثر» در همان ایندکس.
indexes = [
models.Index(
fields=[
'actionType', 'status', 'customer_id',
'actionDateTime', 'finishDateTime',
],
name='act_visit_idx',
),
]
+77 -33
View File
@@ -21,14 +21,14 @@
همیشه خاموش نمی‌ماند.
"""
import logging
import os
from datetime import datetime, time, timedelta
from django.utils import timezone
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
from ..visit_policy import (as_aware, cycle_anchor_map, next_appointment_map,
visit_latest_due, visit_period)
from .enqueue import enqueue_sms
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
@@ -38,24 +38,39 @@ def _template(sms_type):
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند)."""
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند).
در روزهای تغییر ساعت، نیمه‌شب ممکن است وجود نداشته باشد؛ آن حالت هم پوشش داده
می‌شود تا یک ``installDate`` روی مرز DST اجرای کل اسکن را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
try:
return timezone.make_aware(value, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
value, timezone.get_current_timezone(), is_dst=False,
)
return value
# date → آغاز همان روز
return timezone.make_aware(
datetime.combine(value, time.min), timezone.get_current_timezone(),
)
moment = datetime.combine(value, time.min)
try:
return timezone.make_aware(moment, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
moment, timezone.get_current_timezone(), is_dst=False,
)
class _Budget:
"""ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
def __init__(self, config):
def __init__(self, config, now=None):
self.since = config.scan_since
# مرجع زمانی «خط شروع»؛ برای اسکن با زمان تزریقی (تست/بازپخش) هم‌زمان می‌شود.
self.now = now
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
@@ -63,8 +78,21 @@ class _Budget:
self.backlog_count = 0
def is_backlog(self, trigger):
"""آیا این مورد پیش از «خط شروع» واجد شرط شده است؟
مرجع زمانی، «اکنونِ همین اجرا» است (``self.now``)؛ برای اسکن عادی همان
``timezone.now()`` و برای اسکن با زمان تزریقی همان لحظهٔ تزریق‌شده.
"""
trigger = _aware(trigger)
return self.since is not None and trigger < self.since
since = self.since
if since is not None and self.now is not None:
moment = _aware(self.now)
now_ref = timezone.now()
if moment is not None and now_ref is not None:
# پنجره با «همان لحظه‌ای» که اسکن برایش اجرا می‌شود سنجیده شود،
# وگرنه اسکنِ بازپخش/تست همیشه همه‌چیز را «قدیمی» می‌بیند.
since = since + (moment - now_ref)
return since is not None and trigger < since
def can_send(self, trigger):
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
@@ -166,50 +194,66 @@ def scan_nesiye_due(today):
return count
def scan_visit(today):
def scan_visit(today, *, now=None):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
مشتریانی که هرگز بازدید نشده‌اند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
تلقی می‌شود و فقط با اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید معتبر + دورهٔ مجاز» (یا تاریخ نصب
برای مشتریانی که هرگز بازدید نشده‌اند). مبنای «آخرین بازدید» و دوره، هر دو از
``admin_panel.visit_policy`` می‌آیند تا این اسکنر و صفحهٔ ``entezar`` هرگز
دو جواب متفاوت ندهند. مشتری دارای «تعویق فعال» (هماهنگی در انتظار) یادآوری
نمی‌گیرد، چون قرار بعدی از قبل با او گذاشته شده است.
``now`` برای تست/بازپخش قابل تزریق است؛ پیش‌فرض «همین حالا» است.
اگر لحظهٔ واجد‌شرط‌شدن پیش از خط شروع باشد، مورد قدیمی تلقی می‌شود و فقط با
اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
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()
period = visit_period()
now = timezone.now() if now is None else as_aware(now)
budget = _Budget(SMSProviderConfig.load(), now=now)
# لنگر چرخه: آخرین «بازدید» یا «نصب» (تصمیم محصولی). تماس تلفنی چرخه را ریست نمی‌کند.
anchors = cycle_anchor_map(now=now)
# قرار بعدی (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) ⇒ یادآوری لازم نیست.
appointments = next_appointment_map(now=now)
# مرز تاریخ‌ها باید به وقت **محلی** گرفته شود؛ `.date()` روی دیتاتایم aware،
# تاریخ UTC می‌دهد که در ساعت‌های اول شب تهران یک روز عقب‌تر است و باعث می‌شد
# مشتریِ دقیقاً روی مرز، از اسکن جا بیفتد.
cutoff_date = (now - period).astimezone(timezone.get_current_timezone()).date()
qs = Customer.objects.filter(
laghv_bazdid=False,
is_active=True,
installDate__lte=(now - period).date(),
installDate__lte=cutoff_date,
).order_by("id")
count = 0
for customer in qs:
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
if customer.id in appointments:
continue # قرار بعدی ثبت شده است؛ یادآوری لازم نیست
anchor = anchors.get(customer.id) or _aware(customer.installDate)
if anchor is None:
continue
trigger = anchor + period
if trigger > now:
# «تازه‌ترین» موعدی که رسیده است (۹ ماه، ۱۸ ماه، ۲۷ ماه…).
# قاعدهٔ قبلی همیشه موعد اول را می‌سنجید و همین باعث می‌شد دوره‌های بعدی
# هرگز ساخته نشوند.
trigger = visit_latest_due(anchor, now=now)
# موعد یک «تاریخ» است نه یک لحظه؛ اگر روزِ موعد فرا رسیده باشد باید صف شود.
if trigger is None or trigger.date() > now.astimezone(
timezone.get_current_timezone()
).date():
continue # هنوز عقب‌افتاده نیست
if not budget.can_send(trigger):
continue
# چرخه = شمارهٔ دورهٔ کاملِ سپری‌شده از لنگر. کلید به چرخه گره می‌خورد تا
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دوره‌های بعدی
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
cycle = max((today - anchor.date()).days // period_days, 0)
# کلید یکتاسازی = «موعد تقویمیِ» همان دوره، نه شمارش روز.
# با این قاعده هر دوره دقیقاً یک کلید دارد: ۹ ماه، ۱۸ ماه، ۲۷ ماه… .
# (قاعدهٔ قبلی — days // 274 — با موعد تقویمی هم‌تراز نبود و باعث
# گم‌شدنِ یک دوره و تکرار/پرش شمارهٔ چرخه می‌شد.)
due_month = trigger.strftime("%Y-%m")
made = enqueue_sms(
customer=customer, sms_type=SMSType.VISIT,
context={"name": customer.name_family},
dedup_key=f"visit:{customer.id}:{cycle}",
dedup_key=f"visit:{customer.id}:{due_month}",
)
if made:
budget.record(trigger)
+83 -60
View File
@@ -1,13 +1,19 @@
import datetime
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse
from django.contrib import messages
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
as_aware, normalize_finish_datetime,
refresh_last_bazdid, validate_action_payload)
@login_required(login_url='/panel/p_login/')
@@ -54,46 +60,68 @@ def customerNazar(request ,c_id):
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/customer/nazar.html', data)
def _parse_form_datetime(raw):
"""timestamp میلی‌ثانیه‌ای فرم → دیتاتایم aware (یا ``None``).
کد قبلی ``datetime.datetime.fromtimestamp`` (naive) می‌ساخت که هنگام ذخیره
با ``USE_TZ=True`` هشدار ``RuntimeWarning`` می‌داد؛ اینجا صریح aware می‌شود.
"""
try:
return as_aware(datetime.datetime.fromtimestamp(int(raw) / 1e3))
except (TypeError, ValueError, OverflowError, OSError):
return None
def _action_payload_from_request(request):
"""استخراج و نرمال‌سازی ورودی فرم اقدام (مشترک ایجاد و ویرایش)."""
is_done = bool(request.POST.get('is_done'))
return {
'actionType': (request.POST.get('actionType') or '').strip(),
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
'is_done': is_done,
}
def _reject(request, error, redirect_to):
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
messages.error(request, '؛ '.join(error.messages))
return redirect(redirect_to)
@login_required(login_url='/panel/p_login/')
def customerNewAction(request ,c_id):
actionType = request.POST.get('actionType', None)
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
#return HttpResponse(float(dateTimeAnjam+".0"))
payload = _action_payload_from_request(request)
try:
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
except:
pass
desc = request.POST.get('desc', None)
userID = request.POST.get('userID', None)
is_done = request.POST.get('is_done', None)
result = request.POST.get('result', None)
FinishDateTime = request.POST.get('FinishDateTime', None)
try:
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
except:
pass
validate_action_payload(payload)
except ValidationError as error:
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action()
newAction.actionType = actionType
newAction.actionDateTime = dateTimeAnjam
newAction.desc = desc
newAction.forUser = User.objects.get(id=int(userID))
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
newAction.desc = payload['desc']
newAction.forUser = User.objects.get(id=int(payload['userID']))
newAction.customer = customer_obj
if is_done:
newAction.status = 'finish'
newAction.finishDateTime = FinishDateTime
newAction.result = result
if payload['is_done']:
newAction.status = Action.Status.FINISH
# تاریخ اتمام خالی نباید None ذخیره شود (سابقه را پاک می‌کرد).
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
newAction.result = payload['result']
else:
newAction.status = 'waite'
newAction.status = Action.Status.WAITE
newAction.save()
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == 'bazdid':
if newAction.actionType == Action.ActionType.BAZDID:
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
@@ -116,42 +144,37 @@ def ActionListUser(request, u_id):
@login_required(login_url='/panel/p_login/')
def customerEditAction(request ,c_id, action_id):
actionType = request.POST.get('actionType', None)
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
print("ok")
#return HttpResponse(float(dateTimeAnjam+".0"))
payload = _action_payload_from_request(request)
try:
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
except:
pass
desc = request.POST.get('desc', None)
userID = request.POST.get('userID', None)
is_done = request.POST.get('is_done', None)
result = request.POST.get('result', None)
FinishDateTime = request.POST.get('FinishDateTime', None)
try:
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
except:
pass
validate_action_payload(payload)
except ValidationError as error:
return _reject(
request, error,
request.META.get('HTTP_REFERER', '/panel/customer/' + str(c_id) + '/action'),
)
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
newAction.actionType = actionType
newAction.actionDateTime = dateTimeAnjam
newAction.desc = desc
newAction.forUser = User.objects.get(id=int(userID))
was_done = newAction.status == Action.Status.FINISH
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
newAction.desc = payload['desc']
newAction.forUser = User.objects.get(id=int(payload['userID']))
newAction.customer = customer_obj
if is_done:
newAction.status = 'finish'
newAction.finishDateTime = FinishDateTime
newAction.result = result
if payload['is_done']:
newAction.status = Action.Status.FINISH
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
newAction.result = payload['result']
else:
newAction.status = 'waite'
newAction.status = Action.Status.WAITE
newAction.save()
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
# بازمحاسبهٔ واحد: هم پس از ویرایش، هم وقتی رکورد از «انجام‌شده» برگشت می‌خورد
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود).
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@@ -205,7 +228,7 @@ def ActionListUserToday(request, u_id):
@login_required(login_url='/panel/p_login/')
def rezayat(request):
statusRezayat = request.GET.get('status', 'waite')
now = datetime.datetime.now()
now = timezone.now()
# محاسبه بازه‌های زمانی ۳۰ روز و ۶۰ روز قبل
dt_30_days_ago = now - datetime.timedelta(days=30)
@@ -246,20 +269,20 @@ def rezayatEdit(request, ac_id):
# عدم موفقیت: تنظیم nextNazarDT برای ۳ روز بعد (nazarDT پر نمی‌شود تا نهایی نشود)
action.nazar = csDesc
action.grade = int(csGrade)
action.nextNazarDT = datetime.datetime.now() + datetime.timedelta(days=3)
action.nextNazarDT = timezone.now() + datetime.timedelta(days=3)
elif csGrade == '-2':
# کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = datetime.datetime.now()
action.nazarDT = timezone.now()
action.nextNazarDT = None
else:
# ثبت امتیاز عادی
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = datetime.datetime.now()
action.nazarDT = timezone.now()
action.nextNazarDT = None
action.save()
+35 -34
View File
@@ -5,6 +5,7 @@ from django.db import models
from django.db.models import Sum, Q
from django.shortcuts import redirect, render, get_object_or_404
from django.core.paginator import Paginator
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
@@ -17,6 +18,7 @@ from ..sms.phones import (normalize_primary_values,
save_primary_normalized)
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
from ..visit_policy import (last_visit_map, next_appointment_map, visit_due_at)
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -327,56 +329,55 @@ def customerEdit(request, c_id):
@login_required(login_url='/panel/p_login/')
def entezar(request):
a = datetime.datetime.now()
"""فهرست «منتظر بازدید».
منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کش‌شدهٔ
``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
از همان قالب پیامک خوانده می‌شود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده می‌شود.
"""
searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '')
searchStatus = True
full_path = request.get_full_path()
from django.db.models import Q
now = timezone.now()
due_cut = visit_due_at(now=now)
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
bazdid = os.environ.get('bazdid', 9)
bazdid = int ( int(bazdid) * 4.3 )
dateTime6MonthAgo = a - datetime.timedelta(weeks=bazdid)
''' customers = Customer.objects.filter(name_family__isnull=False)
m6mbazdid = []
for customerObj in customers:
try:
actionAa = Action.objects.filter(customer=customerObj, actionType='bazdid', status='finish').last()
if actionAa.status == 'finish':
customerObj.last_bazdid_dt = actionAa.actionDateTime
customerObj.save()
if actionAa.actionDateTime.date() < dateTime6MonthAgo.date():
m6mbazdid.append(actionAa)
except:
pass '''
visits = last_visit_map(now=now)
appointments = next_appointment_map(now=now)
customers_objj = Customer.objects.filter(
laghv_bazdid=False, name_family__isnull=False,
installDate__lte=due_cut_date,
)
if searchOn == 'address':
print("addressaddressaddressaddressaddress")
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False, address__contains=searchVal).order_by('last_bazdid_dt')
customers_objj = customers_objj.filter(address__contains=searchVal)
else:
searchStatus = False
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False).order_by('last_bazdid_dt')
#customers_objj = Customer.objects.filter(name_family__isnull=False, last_bazdid_dt__lte=dateTime6MonthAgo)
# مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
stale_ids = [
customer_id for customer_id in customers_objj.values_list('id', flat=True)
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
]
customers_objj = customers_objj.filter(id__in=stale_ids)
# «قرار بعدی» (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) یعنی این مشتری
# در این فهرست جایی ندارد تا آن تاریخ.
pending_ids = [cid for cid in stale_ids if cid in appointments]
if pending_ids:
customers_objj = customers_objj.exclude(id__in=pending_ids)
customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
entezar_bazdid.value = Customer.objects.filter(
Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False).count()
entezar_bazdid.value = len(stale_ids) - len(pending_ids)
entezar_bazdid.save()
except:
except CacheTable.DoesNotExist:
pass
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
+19 -4
View File
@@ -597,6 +597,10 @@ def sms_queue_delete(request, q_id):
if request.method == "POST":
if item.status in DONE_STATUSES:
return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked")
# تصمیم محصولی: حذف توسط مدیر = «کنسل همین دوره». برای همین کلید یکتاسازی
# پیش از حذف در دفتر ثبت می‌شود؛ وگرنه اسکنر در اجرای بعدی همان پیامک را
# دوباره می‌ساخت.
_remember_keys([item])
item.delete()
return _notice(reverse("admin_panel:sms_queue"), "deleted")
return redirect("admin_panel:sms_queue")
@@ -621,6 +625,7 @@ def sms_queue_bulk(request):
# تاریخچهٔ ارسال‌شده‌ها از طریق صف حذف نمی‌شود.
selected = selected.exclude(status__in=DONE_STATUSES)
count = selected.count()
_remember_keys(selected)
selected.delete()
code, extra = "bulk_deleted", {"count": count}
else:
@@ -629,6 +634,19 @@ def sms_queue_bulk(request):
return redirect("admin_panel:sms_queue")
def _remember_keys(rows):
"""کلیدهای یکتاسازیِ رکوردهای صف را پیش از حذف در دفتر ``SMSDedup`` ثبت می‌کند.
قرارداد: وقتی مدیر یادآوری را حذف می‌کند، یعنی «همین دوره کنسل شد»؛ وگرنه
اسکنر در اجرای بعدی همان پیامک را از نو می‌سازد و حذفِ مدیر بی‌اثر می‌شود.
"""
keys = [k for k in (row.dedup_key for row in rows) if k]
if keys:
SMSDedup.objects.bulk_create(
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
@@ -640,10 +658,7 @@ def sms_queue_empty(request):
"""
if request.method == "POST":
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
keys = [k for k in rows.values_list("dedup_key", flat=True) if k]
SMSDedup.objects.bulk_create(
[SMSDedup(key=k) for k in keys], ignore_conflicts=True,
)
_remember_keys(rows)
count = rows.count()
rows.delete()
return _notice(reverse("admin_panel:sms_queue"), "queue_emptied", count=count)
+514
View File
@@ -0,0 +1,514 @@
"""سیاست بازدید مشتری — تنها منبع حقیقت برای «آخرین بازدید واقعی».
هدف این ماژول پایان‌دادن به سه تعریف واگرا از «آخرین بازدید» است:
* ویوهای ``customerNewAction``/``customerEditAction`` که قبلاً مستقیماً
``last_bazdid_dt = finishDateTime`` می‌نوشتند (بدون بررسی ترتیب و بدون مبنا).
* صفحهٔ ``/panel/entezar/`` که فقط به ستون کش‌شدهٔ ``Customer.last_bazdid_dt`` تکیه می‌کرد.
* اسکنر پیامک (``admin_panel/sms/scanner.py``) که همان ستون را به‌عنوان لنگر می‌خواند.
قواعد ثابت این ماژول:
۱. فقط اقدام ``actionType == 'bazdid' AND status == 'finish'`` در محاسبه دیده می‌شود.
«هماهنگی» (``hamahangi``) و بقیهٔ انواع هرگز وارد
:func:`last_visit_dt` نمی‌شوند — نه امروز، نه در آینده. تعویق/هماهنگی مفهوم جداگانه‌ای
است و فقط از :func:`active_deferrals` خوانده می‌شود.
۲. تاریخ مؤثر هر رکورد = ``actionDateTime`` **اگر از حالا جلوتر نباشد**؛ وگرنه
``finishDateTime`` اگر آن هم از حالا جلوتر نباشد؛ وگرنه «تاریخ نامعتبر».
قاعدهٔ قدیمی «اگر finish کوچک‌تر از action بود، finish را بگیر» عمداً حذف شده است:
در دادهٔ واقعی، آن حالت همان جایی است که ``finishDateTime`` برابر زمان ثبت رکورد
است و «زمان ثبت» را جای «تاریخ بازدید» می‌گذاشت.
۳. همهٔ مقایسه‌ها با تاریخ aware انجام می‌شود؛ مقادیر naive موجود در دیتابیس
(بقایای کد قدیمی) با همان منطقهٔ زمانی پروژه aware می‌شوند.
"""
import logging
from datetime import date, datetime, time, timezone as dt_timezone
from dateutil import relativedelta
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Case, DateTimeField, F, Max, Min, Value, When
from django.utils import timezone
from .models import Action, Customer, SMSTemplate
from .sms.timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# دلیل نوشتن ``last_bazdid_dt`` (برای لاگ؛ با ستون‌های حسابرسی اختیاری هم‌خوان است).
REASON_CREATE = "create"
REASON_EDIT = "edit"
REASON_CANCEL = "cancel"
REASON_RECALC = "recalc"
REASON_MANUAL = "manual"
# سقف مجاز «تاریخ آینده» برای بازدید انجام‌شده در اعتبارسنجی HTTP.
FINISH_MAX_FUTURE_DAYS = 30
# فقط این نوع از اقدام‌ها بازدید هستند (ثابت مدل، نه رشتهٔ خام).
_VISIT_TYPE = Action.ActionType.BAZDID
_VISIT_DONE = Action.Status.FINISH
# ---------------------------------------------------------------------------
# تبدیل تاریخ
# ---------------------------------------------------------------------------
def as_aware(value):
"""تاریخ/دیتاتایم naive یا ``date`` را به ``datetime`` aware تبدیل می‌کند.
برای مقادیر ``date``، «آغاز روز» استفاده می‌شود. در روزهای تغییر ساعت
(مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته باشد؛ در آن حالت
``is_dst=None`` خطای ``NonExistentTimeError`` می‌دهد، پس با ``is_dst=False``
ذخیره می‌شود تا هیچ‌گاه یک تاریخِ درست، اجرای اسکن/گزارش را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
try:
return timezone.make_aware(value, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
value, timezone.get_current_timezone(), is_dst=False,
)
return value
if isinstance(value, date):
moment = datetime.combine(value, time.min)
try:
return timezone.make_aware(moment, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
moment, timezone.get_current_timezone(), is_dst=False,
)
return None
def _now(now=None):
return as_aware(now) if now is not None else timezone.now()
def effective_visit_dt(action, *, now=None):
"""تاریخ مؤثر یک اقدام بازدید انجام‌شده (یا ``None`` اگر تاریخ معتبری ندارد).
قاعده در docstring ماژول آمده است. رکورد نامعتبر در محاسبه نادیده گرفته می‌شود
و مقدار موجود مشتری پاک نمی‌شود.
"""
if action is None:
return None
now = _now(now)
for value in (as_aware(action.actionDateTime), as_aware(action.finishDateTime)):
if value is not None and value <= now:
return value
return None
def _visit_rows(customer_ids=None):
"""پیمایش سبک رکوردهای بازدید انجام‌شده (فقط فیلدهای لازم)."""
qs = Action.objects.filter(
actionType=_VISIT_TYPE, status=_VISIT_DONE,
).values_list("customer_id", "actionDateTime", "finishDateTime")
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
return qs.iterator()
def _last_visit_map_python(*, now=None, customer_ids=None):
"""نسخهٔ مرجع (پایتونی) برای تست همسانی و پشتیبانِ اضطراری.
این همان منطقی است که از ابتدا در ``last_visit_map`` بود؛ نسخهٔ SQL باید
دقیقاً همان خروجی را بدهد. در تست‌ها به‌عنوان «منبع مقایسه» استفاده می‌شود.
"""
now = _now(now)
result = {}
invalid = 0
for customer_id, action_dt, finish_dt in _visit_rows(customer_ids):
eff = None
for value in (as_aware(action_dt), as_aware(finish_dt)):
if value is not None and value <= now:
eff = value
break
if eff is None:
invalid += 1
continue
current = result.get(customer_id)
if current is None or eff > current:
result[customer_id] = eff
if invalid:
logger.warning(
"بازدید انجام‌شدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s رکورد", invalid,
)
return result
def last_visit_map(*, now=None, customer_ids=None):
"""نگاشت ``customer_id → تاریخ آخرین بازدید معتبر`` — با تجمیع در دیتابیس.
قاعدهٔ تاریخ مؤثر همان قاعدهٔ قبلی است (هیچ تغییری در معنا):
* فقط ``bazdid + finish``.
* ``actionDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
* وگرنه ``finishDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
* وگرنه رکورد «تاریخ معتبر» ندارد و باید نادیده گرفته شود.
محاسبه در SQL انجام می‌شود (``CASE`` داخل ``MAX``) نه با حلقهٔ پایتونی روی
۴۵ هزار رکورد؛ خروجی همان دیکشنری قبلی است.
"""
now = _now(now)
# مقایسهٔ «حالا» باید در SQL انجام شود. نکتهٔ مهم: اگر یک datetime naive به ORM
# داده شود، Django آن را با منطقهٔ زمانی محلی دوباره adapt می‌کند (۳:۳۰ ساعت
# جابه‌جایی برای تهران)، و اگر رشته هم بدهیم آن را به‌عنوان datetime تفسیر می‌کند.
# راه امن: مقدار را در پایتون به «naive UTC» تبدیل و سپس با ``Value`` تایپ‌دار
# به‌عنوان DateTimeField بفرستیم تا تبدیل دومی رخ ندهد.
now_utc = now.astimezone(dt_timezone.utc)
effective = Case(
# شاخهٔ اول: تاریخ بازدید (اگر وجود دارد و در آینده نیست).
When(actionDateTime__isnull=False, actionDateTime__lte=now_utc,
then=F("actionDateTime")),
# شاخهٔ دوم: تاریخ اتمام (اگر وجود دارد و در آینده نیست).
When(finishDateTime__isnull=False, finishDateTime__lte=now_utc,
then=F("finishDateTime")),
default=Value(None, output_field=DateTimeField()),
output_field=DateTimeField(),
)
qs = (
Action.objects.filter(actionType=_VISIT_TYPE, status=_VISIT_DONE)
.values("customer_id")
.annotate(latest=Max(effective))
)
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
result = {}
invalid = 0
for row in qs.iterator():
value = row["latest"]
if value is None:
invalid += 1
continue
result[row["customer_id"]] = as_aware(value)
if invalid:
logger.warning(
"بازدید انجام‌شدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s مشتری", invalid,
)
return result
def last_visit_dt(customer, *, now=None):
"""تاریخ آخرین بازدید معتبر یک مشتری (یا ``None``)."""
if customer is None or customer.pk is None:
return None
return last_visit_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
# ---------------------------------------------------------------------------
# لنگر چرخهٔ یادآوری (بازدید + نصب)
# ---------------------------------------------------------------------------
# تصمیم محصولی: «نصب» هم چرخهٔ ۹ ماههٔ یادآوری را از نو شروع می‌کند؛ ولی «تماس
# تلفنی» (inCall/outCall) و «هماهنگی» خیر. این لنگر **فقط** برای زمان‌بندی
# یادآوری است و هرگز جای ``last_bazdid_dt`` (که یعنی «آخرین بازدید») را نمی‌گیرد.
_CYCLE_RESET_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
def cycle_anchor_map(*, now=None, customer_ids=None):
"""نگاشت ``customer_id → آخرین رخدادِ شروع‌دوبارهٔ چرخه`` (بازدید یا نصب).
قاعدهٔ تاریخ مؤثر همان قاعدهٔ بازدید است: ``actionDateTime`` اگر از حالا
جلوتر نباشد، وگرنه ``finishDateTime``؛ وگرنه رکورد بی‌اثر است.
"""
now = _now(now)
qs = Action.objects.filter(
actionType__in=_CYCLE_RESET_TYPES, status=_VISIT_DONE,
).values_list("customer_id", "actionDateTime", "finishDateTime")
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
result = {}
for customer_id, action_dt, finish_dt in qs.iterator():
eff = None
for value in (as_aware(action_dt), as_aware(finish_dt)):
if value is not None and value <= now:
eff = value
break
if eff is None:
continue
current = result.get(customer_id)
if current is None or eff > current:
result[customer_id] = eff
return result
def cycle_anchor_dt(customer, *, now=None):
"""لنگر چرخهٔ یادآوری یک مشتری (بازدید یا نصب؛ یا ``None``)."""
if customer is None or customer.pk is None:
return None
return cycle_anchor_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
# ---------------------------------------------------------------------------
# نوشتن مقدار روی مشتری
# ---------------------------------------------------------------------------
def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
"""``Customer.last_bazdid_dt`` را از منبع حقیقت بازمحاسبه می‌کند.
* فقط وقتی مقدار تغییر کند می‌نویسد (idempotent).
* اگر مشتری هیچ بازدید معتبری ندارد، مقدار فعلی **دست‌نخورده** می‌ماند
(پاک‌سازی فقط با دستور ``recalc_last_bazdid --clear-orphans`` مجاز است).
* هرگز ``Action`` را ذخیره/تغییر نمی‌دهد و ``Customer.save()`` کامل صدا نمی‌زند
(تا فیلدهای ``auto_now`` و بقیهٔ ستون‌ها دست‌نخورده بمانند).
"""
if customer is None or customer.pk is None:
return False
new_value = last_visit_dt(customer, now=now)
current = as_aware(customer.last_bazdid_dt)
if new_value is None:
logger.warning(
"مشتری %s بازدید معتبری ندارد؛ last_bazdid_dt دست‌نخورده ماند "
"(مقدار فعلی: %s)", customer.pk, current,
)
return False
if current == new_value:
return False
Customer.objects.filter(pk=customer.pk).update(last_bazdid_dt=new_value)
customer.last_bazdid_dt = new_value
logger.info(
"last_bazdid_dt مشتری %s از %s به %s تغییر کرد (دلیل=%s، کاربر=%s)",
customer.pk, current, new_value, reason,
getattr(user, "pk", None),
)
return True
# ---------------------------------------------------------------------------
# تعویق / قرار بعدی — هرگز در last_visit_dt دیده نمی‌شود
# ---------------------------------------------------------------------------
# تصمیم محصولی: هر کدام از این دو، «قرار بعدی با مشتری» است و مشتری تا آن تاریخ
# از فهرست «منتظر بازدید» خارج می‌شود:
#
# ۱. اقدام **هماهنگی** که با مشتری توافق شده است — چه هنوز در انتظار باشد و چه
# همان لحظه «انجام شد» بسته شود (``finish``). تاریخ قرار = ``actionDateTime``.
# اگر هماهنگیِ فینیش تاریخ نداشته باشد، از ``finishDateTime`` استفاده می‌شود.
# ۲. اقدام **بازدید** که برای آینده برنامه‌ریزی شده است (``waite`` با تاریخ آینده).
#
# «هماهنگی» بازدید نیست؛ پس هیچ‌کدام از این‌ها ``last_bazdid_dt`` را تغییر نمی‌دهد.
def next_appointment_map(*, now=None):
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
منابع قرار (فقط آینده):
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامه‌ریزی‌شده).
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
فقط برای ``entezar`` و اسکنر پیامک استفاده می‌شود و هرگز در «آخرین بازدید»
دخالت نمی‌کند.
"""
now = _now(now)
# همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
# دوباره با منطقهٔ محلی adapt نکند.
now_utc = now.astimezone(dt_timezone.utc)
# چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینه‌ساز SQLite از ایندکس
# ``act_visit_idx`` استفاده نمی‌کند و کوئری ~۱۹۳ms طول می‌کشد. با تقسیم به
# کوئری‌های ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا می‌شوند.
# (از ``union()`` استفاده نمی‌کنیم چون در این ترکیب، ردیف‌ها را از دست می‌دهد.)
hamahangi = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
).filter(
models.Q(actionDateTime__gt=now_utc)
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
).values_list("customer_id", "actionDateTime", "finishDateTime")
planned_visit = Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
actionDateTime__gt=now_utc,
).values_list("customer_id", "actionDateTime")
combined = {}
def _remember(customer_id, when):
when = as_aware(when)
if when is None:
return
current = combined.get(customer_id)
if current is None or when < current:
combined[customer_id] = when
for customer_id, action_dt, finish_dt in hamahangi.iterator():
# تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
_remember(customer_id, action_dt or finish_dt)
for customer_id, action_dt in planned_visit.iterator():
_remember(customer_id, action_dt)
return combined
def next_appointment(customer, *, now=None):
"""زودترین قرار بعدی یک مشتری (یا ``None``)."""
if customer is None or customer.pk is None:
return None
return next_appointment_map(now=now).get(customer.pk)
# نام قدیمی برای سازگاری با فراخوانی‌های موجود.
def active_deferrals(*, now=None):
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقب‌رو)."""
return next_appointment_map(now=now)
def active_deferral(customer, *, now=None):
"""نام قدیمیِ :func:`next_appointment` (سازگاری عقب‌رو)."""
return next_appointment(customer, now=now)
# ---------------------------------------------------------------------------
# دورهٔ بازدید (منبع واحد)
# ---------------------------------------------------------------------------
def visit_period_months():
"""تعداد ماهِ دورهٔ بازدید از قالب پیامک (قابل تنظیم از پنل)، وگرنه پیش‌فرض."""
value = (
SMSTemplate.objects.filter(sms_type="visit")
.values_list("repeat_interval_months", flat=True)
.first()
)
return int(value or DEFAULT_VISIT_MONTHS)
def visit_period(months=None):
"""دورهٔ بازدید به‌صورت بازهٔ تقویمی (ماه واقعی، نه ۴٫۳ هفته)."""
months = int(months or visit_period_months())
return relativedelta.relativedelta(months=max(months, 1))
def visit_period_days(months=None):
"""طول تقریبی دوره به روز (فقط برای گزارش/نمایش؛ مبنای چرخه نیست)."""
return max(round(int(months or visit_period_months()) * 365 / 12), 1)
def visit_due_at(*, now=None, months=None):
"""مقطعی که مشتریِ بدون بازدید از آن پیش‌تر «عقب‌افتاده» حساب می‌شود."""
return _now(now) - visit_period(months)
def visit_due_date(anchor, *, months=None):
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
"""
anchor = as_aware(anchor)
if anchor is None:
return None
return anchor + visit_period(months)
def visit_cycle_index(anchor, at, *, months=None):
"""شمارهٔ چرخهٔ یادآوریِ ``at`` نسبت به لنگر، بر مبنای **ماه تقویمی**.
چرخهٔ ۱ یعنی «یک دورهٔ کامل گذشته و یادآوری اول واجد شرط شده است».
چرا ماه تقویمی و نه تقسیم روز؟ در نسخهٔ قبلی شمارهٔ چرخه با
``days // 274`` حساب می‌شد و با موعدِ تقویمی (``relativedelta(months=9)``)
بر یک شبکه نمی‌نشست؛ نتیجه‌اش *گم‌شدنِ* بعضی دوره‌ها (کلید تکراری) و
*پریدن* بعضی دیگر بود. حالا هر دو از یک قاعده می‌آیند:
«تعداد ماهِ کاملِ سپری‌شده از لنگر ÷ طول دوره».
"""
anchor = as_aware(anchor)
at = as_aware(at)
if anchor is None or at is None or at < anchor:
return 0
period_months = max(int(months or visit_period_months()), 1)
months_elapsed = (at.year - anchor.year) * 12 + (at.month - anchor.month)
period = relativedelta.relativedelta(months=period_months)
def due_date(steps):
"""تاریخِ موعدِ ``steps`` دوره بعد، با جمعِ **پله‌ای**.
چرا ضرب نکنیم؟ ``relativedelta * n`` انتهای ماه را تجمعی می‌بندد
(۳۱ ژانویه → ۳۰ آوریل → ۳۰ جولای) در حالی که موعد واقعی ۳۱ جولای است.
جمعِ پله‌ای همان چیزی است که در عمل هم رخ می‌دهد: «یک دوره، بعد یک دورهٔ دیگر».
مقایسه در سطح **تاریخ** انجام می‌شود (نه لحظه)، تا ساعتِ اجرای اسکن
(مثلاً ۱۲:۰۰ همان روز موعد) تأثیری در شمارش نداشته باشد.
"""
value = anchor.date()
for _ in range(steps):
value = (as_aware(value) + period).date()
return value
today = at.date()
# بزرگ‌ترین kای که موعدش رسیده است.
k = max(months_elapsed // period_months, 0)
while k > 0 and due_date(k) > today:
k -= 1
while due_date(k + 1) <= today:
k += 1
return k
def visit_latest_due(anchor, *, now, months=None):
"""تازه‌ترین موعدی که تا ``now`` رسیده است (یا ``None``).
مشتری‌ای که ۲۵ ماه بازدید نشده باید موعد ۱۸ ماهگی را بگیرد، نه موعد ۹ ماهگیِ
قدیمی. قاعدهٔ قبلی هر بار ``anchor + یک دوره`` را می‌سنجید؛ چون آن موعد همیشه
«رسیده» بود و کلیدش هم ثابت، دوره‌های بعدی هرگز بررسی نمی‌شدند.
"""
anchor = as_aware(anchor)
now = as_aware(now)
if anchor is None or now is None:
return None
current = visit_cycle_index(anchor, now, months=months)
if current < 1:
return None
period = visit_period(months)
# جمع پله‌ای روی **تاریخ** (نه روی دیتاتایم aware): با تغییر ساعت، جمع ساعت‌دار
# ممکن است به ۲۳:۰۰ روز قبل بغلتد و موعد یک روز زودتر حساب شود.
due = anchor.date()
for _ in range(current):
due = (as_aware(due) + period).date()
return as_aware(due)
# ---------------------------------------------------------------------------
# اعتبارسنجی لایهٔ HTTP
# ---------------------------------------------------------------------------
def validate_action_payload(payload, *, now=None):
"""اعتبارسنجی ورودی فرم اقدام پیش از هر نوشتنی.
``payload`` نگاشتی با کلیدهای ``actionType``، ``status``، ``actionDateTime``
و ``finishDateTime`` است (مقادیر می‌توانند ``None`` یا ``datetime aware`` باشند).
قواعد:
* نوع اقدام باید یکی از شش مقدار مدل باشد.
* وضعیت باید یکی از سه مقدار مدل باشد.
* برای «بازدید انجام‌شده»: تاریخ بازدید نباید بیش از ۳۰ روز در آینده باشد.
* برای هماهنگی و بقیهٔ انواع: هیچ محدودیت تاریخ آینده‌ای اعمال نمی‌شود.
"""
now = _now(now)
action_type = payload.get("actionType")
status = payload.get("status")
if action_type not in Action.ActionType.values:
raise ValidationError("نوع اقدام نامعتبر است.")
if status not in Action.Status.values:
raise ValidationError("وضعیت اقدام نامعتبر است.")
if action_type == _VISIT_TYPE and status == _VISIT_DONE:
action_dt = as_aware(payload.get("actionDateTime"))
if action_dt is not None and action_dt > now + relativedelta.relativedelta(
days=FINISH_MAX_FUTURE_DAYS
):
raise ValidationError(
"تاریخ بازدید نمی‌تواند بیش از ۳۰ روز در آینده باشد."
)
return True
def normalize_finish_datetime(value, *, now=None):
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار می‌گیرد (نه ``None``)."""
value = as_aware(value)
return value if value is not None else _now(now)