improve entezar bazdid
This commit is contained in:
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user