improve bazdid 6

This commit is contained in:
javad committed 2026-10-22 14:01:08 +03:30
1 parent 99c25a8216
commit fa73a734a0
21 files changed
+32760 -7985

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python
"""شبیه‌سازیِ «تعویقِ کوتاه‌تر از تاریخ انجامِ هماهنگی» روی دیتابیس واقعی (کپی).
سناریوی گزارش‌شده: در فرم «ایجاد وظیفه» نوع = هماهنگی،
«تاریخ و ساعتی که کار باید انجام شود» = ۱۴۰۵/۰۸/۰۵ و
«تا تاریخ پیگیری نشود» = ۱۴۰۵/۰۸/۰۳ (یعنی قرار **قبل‌تر** از تاریخ کار).
این اسکریپت روی یک **کپی موقت** از دیتابیس، یک مشتری آزمایشی می‌سازد و رفتار
سیستم را روز‌به‌روز چاپ می‌کند: منابع قرار، فهرست «نیازمند پیگیری»، «عقب‌افتاده»،
و اینکه اسکنر پیامک در هر روز چه می‌کند. دیتابیس اصلی دست‌نخورده می‌ماند.
اجرا::
.venv/Scripts/python.exe scripts/coordination_hold_order_probe.py
.venv/Scripts/python.exe scripts/coordination_hold_order_probe.py --customer 5124
"""
import argparse
import datetime
import os
import shutil
import sys
import tempfile
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
target = os.path.join(target_dir, 'probe.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _day(offset):
return datetime.datetime.combine(
datetime.date.today() + datetime.timedelta(days=offset), datetime.time(12, 0),
)
def run_variant(label, *, done, hold_offset, action_offset):
"""یک سناریو را می‌سازد و رفتار روز‌به‌روز را چاپ می‌کند."""
from django.utils import timezone
from admin_panel.models import (Action, Customer, PhoneNumber, SMSProviderConfig,
SMSQueue)
from admin_panel.sms.scanner import scan_visit
from admin_panel.visit_policy import (appointment_sources_map, end_of_local_day,
followups_on_date, last_visit_map,
manual_holds_map, next_appointment_map,
overdue_followups, set_visit_hold)
tz = timezone.get_current_timezone()
now = timezone.now()
today = now.astimezone(tz).date()
customer = Customer.objects.create(
name_family='مشتری آزمایشی — %s' % label, address='آزمایش',
)
Customer.objects.filter(pk=customer.pk).update(
installDate=today - datetime.timedelta(days=400))
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
customer.refresh_from_db()
action_dt = timezone.make_aware(_day(action_offset))
hold_raw = timezone.make_aware(_day(hold_offset)) if hold_offset is not None else None
action = Action.objects.create(
customer=customer, actionType=Action.ActionType.HAMAHANGI,
status=Action.Status.FINISH if done else Action.Status.WAITE,
actionDateTime=action_dt,
finishDateTime=now if done else None,
desc='probe', result='probe' if done else None,
)
# همان مسیری که ویو طی می‌کند (شامل پیش‌فرض ۱۴ روز و «فقط رو به جلو»).
from admin_panel.views.actions import _apply_visit_hold
_apply_visit_hold(
customer,
{'actionType': 'hamahangi', 'holdRaw': hold_raw, 'status': action.status},
status=action.status, is_new=True,
)
customer.refresh_from_db()
config = SMSProviderConfig.load()
config.scan_since = now - datetime.timedelta(days=600)
config.allow_backlog = True
config.backlog_max = 50
config.max_per_run = 200
config.save()
print('\n' + '=' * 100)
print('سناریو %s' % label)
print(' تیک «انجام شد»: %s → status=%s' % ('خورده' if done else 'نخورده', action.status))
print(' تاریخ کار (actionDateTime): %s' % action.actionDateTime.astimezone(tz))
print(' تا تاریخ پیگیری نشود: %s' % (
customer.visit_hold_until.astimezone(tz) if customer.visit_hold_until
else 'خالی (هیچ تعویقی ثبت نشد)'))
# اثرهای جانبی «تیک انجام شد»: لیست وظایف، شمارندهٔ داشبورد، نظرسنجی.
print(' در «لیست وظایف باز» (ActionList/داشبورد): %s' % (
'بله' if action.status == Action.Status.WAITE else 'خیر'))
survey = (action.status == Action.Status.FINISH
and action.actionType != 'vosol'
and action.nazarDT is None
and action.createdDT >= now - datetime.timedelta(days=60)
and (action.finishDateTime is None
or action.finishDateTime >= now - datetime.timedelta(days=30)))
print(' در صف «نظرسنجی»: %s' % ('بله' if survey else 'خیر'))
# اسکن کامل دیتابیس گران است؛ تصمیمِ اسکنر برای همین مشتری بازسازی می‌شود:
# scanner.py:232 if customer.id in appointments: continue
# scanner.py:240 trigger = visit_latest_due(anchor, now=now)
from admin_panel.visit_policy import cycle_anchor_map, visit_latest_due
def scanner_decision(moment):
appointments = next_appointment_map(now=moment)
if customer.pk in appointments:
return 'رد (قرار بعدی دارد)'
anchors = cycle_anchor_map(now=moment)
anchor = anchors.get(customer.pk)
if anchor is None:
return 'رد (لنگر ندارد)'
trigger = visit_latest_due(anchor, now=moment)
if trigger is None or trigger.date() > moment.astimezone(tz).date():
return 'رد (موعد نرسیده)'
return 'ارسال یادآوری دوره‌ای'
for day_offset in range(0, action_offset + 3):
moment = timezone.make_aware(_day(day_offset))
sources = appointment_sources_map(now=moment)
nexts = next_appointment_map(now=moment)
holds = manual_holds_map(now=moment)
entries = dict(sources.get(customer.pk) or {})
day = moment.astimezone(tz).date()
follow = followups_on_date(day, now=moment)
overdue = overdue_followups(now=moment, target_date=day)
visited = last_visit_map(now=moment, customer_ids=[customer.pk])
badge_days = {value.astimezone(tz).date() for value in entries.values()}
scan_cell = scanner_decision(moment)
print(' %s | منابع=%-34s برچسب=%d قرار | قرار بعدی=%-11s | پیگیری=%s'
' | عقب‌افتاده=%s | قرار دستی=%s | %s' % (
day, '+'.join(sorted(entries)) or '—', len(badge_days),
(nexts.get(customer.pk).astimezone(tz).date()
if nexts.get(customer.pk) else '—'),
'بله' if customer.pk in follow else 'خیر',
'بله' if customer.pk in overdue else 'خیر',
'بله' if customer.pk in holds else 'خیر',
scan_cell,
))
_ = visited
SMSQueue.objects.filter(customer=customer).delete()
Action.objects.filter(customer=customer).delete()
def show_customer(customer_id):
"""وضعیت فعلی یک مشتری واقعی + خط زمانی اقدام‌هایش (فقط خواندن)."""
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (appointment_sources_map, last_visit_map,
manual_holds_map, next_appointment_map)
tz = timezone.get_current_timezone()
customer = Customer.objects.filter(pk=customer_id).first()
print('\n' + '=' * 100)
print('وضعیت مشتری #%s' % customer_id)
if customer is None:
print(' مشتری پیدا نشد.')
return
print(' نام: %s | فعال: %s | لغو بازدید: %s' % (
customer.name_family, customer.is_active, customer.laghv_bazdid))
print(' visit_hold_until: %s' % customer.visit_hold_until)
sources = appointment_sources_map()
print(' منابع قرار: %s' % {
k: str(v.astimezone(tz)) for k, v in (sources.get(customer.pk) or {}).items()})
print(' قرار بعدی: %s' % next_appointment_map().get(customer.pk))
print(' قرار دستی معتبر: %s' % (customer.pk in manual_holds_map()))
print(' آخرین بازدید: %s' % last_visit_map().get(customer.pk))
print(' اقدام‌ها (۱۰ مورد آخر):')
for act in Action.objects.filter(customer_id=customer_id).order_by('-createdDT')[:10]:
print(' #%-6s %-10s %-7s action=%-19s finish=%-19s created=%s' % (
act.pk, act.actionType, act.status,
act.actionDateTime, act.finishDateTime, act.createdDT))
def main():
parser = argparse.ArgumentParser(description='شبیه‌سازی تعویقِ قبل از تاریخ کار')
parser.add_argument('--customer', type=int, default=None, help='شناسهٔ مشتری واقعی')
parser.add_argument('--skip-variants', action='store_true')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.db import connections
source_db = str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
tmp_dir = tempfile.mkdtemp(prefix='hold_probe_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
if not args.skip_variants:
run_variant('بدون تیک «انجام شد»', done=False, hold_offset=3, action_offset=5)
run_variant('با تیک «انجام شد»', done=True, hold_offset=3, action_offset=5)
run_variant('تعویق بعد از تاریخ کار (سالم)', done=True, hold_offset=10,
action_offset=5)
run_variant('تعویق خالی + تیک (پیش‌فرض ۱۴ روز)', done=True, hold_offset=None,
action_offset=5)
run_variant('تعویق خالی + بدون تیک', done=False, hold_offset=None,
action_offset=5)
if args.customer:
show_customer(args.customer)
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python
"""ممیزیِ فقط-خواندنیِ رفتار «قرار / پیگیری» روی دیتابیس واقعی.
این اسکریپت هیچ نوشتنی روی دیتابیسِ ورودی انجام نمی‌دهد: ابتدا یک **کپی موقت**
از فایل sqlite (به‌همراه ``-wal``/``-shm``) می‌سازد و همهٔ کوئری‌ها و توابع
``admin_panel.visit_policy`` را روی همان کپی اجرا می‌کند.
اجرا::
.venv/Scripts/python.exe scripts/entezar_action_audit.py
.venv/Scripts/python.exe scripts/entezar_action_audit.py --db data9/xxx.sqlite3
.venv/Scripts/python.exe scripts/entezar_action_audit.py --samples 10
"""
import argparse
import os
import shutil
import sys
import tempfile
from collections import Counter
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
"""کپی امنِ dbfile + فایل‌های WAL در پوشهٔ موقت؛ مسیر کپی برگردانده می‌شود."""
target = os.path.join(target_dir, 'audit_copy.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _aware(value, tz):
from django.utils import timezone
if value is None:
return None
if timezone.is_naive(value):
return timezone.make_aware(value, tz)
return value
def _local_date(value, tz):
value = _aware(value, tz)
return None if value is None else value.astimezone(tz).date()
def _pct(part, whole):
return 0.0 if not whole else round(100.0 * part / whole, 1)
def main():
parser = argparse.ArgumentParser(description='ممیزی فقط-خواندنی صفحهٔ انتظار')
parser.add_argument('--db', default=None, help='مسیر دیتابیس (پیش‌فرض: settings)')
parser.add_argument('--samples', type=int, default=5, help='تعداد نمونه در خروجی')
parser.add_argument('--customer', type=int, default=None,
help='شناسهٔ مشتری برای نمایش خط زمانی کامل اقدام‌ها')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL,
appointment_sources_map,
followups_on_date, last_visit_map,
overdue_followups)
source_db = args.db or str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
if not os.path.exists(source_db):
raise SystemExit('دیتابیس پیدا نشد: %s' % source_db)
tmp_dir = tempfile.mkdtemp(prefix='entezar_audit_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
from django.db import connections
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
now = timezone.now()
tz = timezone.get_current_timezone()
today = now.astimezone(tz).date()
# ---------------- داده‌های پایه ----------------
customers = Customer.objects.all().values_list(
'id', 'name_family', 'is_active', 'laghv_bazdid', 'visit_hold_until', 'installDate',
)
customer_rows = {row[0]: row for row in customers}
coord_rows = list(
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
deleted_at__isnull=True,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime',
'createdDT', 'status')
)
visit_rows = list(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.FINISH,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime', 'createdDT')
)
pending_rows = list(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime', 'createdDT')
)
print('مشتریان: %d | هماهنگی فعال: %d | بازدید انجام‌شده: %d | بازدید در انتظار: %d'
% (len(customer_rows), len(coord_rows), len(visit_rows), len(pending_rows)))
visits = last_visit_map(now=now)
sources = appointment_sources_map(now=now)
overdue = overdue_followups(now=now, target_date=today)
today_followups = followups_on_date(today, now=now)
last_coord = {}
for cid, aid, adt, fdt, created, status in coord_rows:
current = last_coord.get(cid)
created_aware = _aware(created, tz)
if current is None or created_aware > current['created']:
last_coord[cid] = {
'id': aid, 'actionDateTime': adt, 'finishDateTime': fdt,
'created': created_aware, 'status': status,
}
visits_by_customer = {}
for row in visit_rows:
visits_by_customer.setdefault(row[0], []).append(row)
def effective(dt, fdt):
"""قاعدهٔ ``effective_visit_dt``: actionDateTime اگر گذشته باشد، وگرنه finish."""
for value in (_aware(dt, tz), _aware(fdt, tz)):
if value is not None and value <= now:
return value
return None
def analyse(cid):
"""تشخیص علت حضور در «نیازمند پیگیری»."""
coord = last_coord.get(cid)
done = visits_by_customer.get(cid) or []
fact = {
'coord_day': _local_date(coord['actionDateTime'], tz) if coord else None,
'coord_created': coord['created'] if coord else None,
'coord_status': coord['status'] if coord else None,
'visits_total': len(done),
'visit_after_coord_created': False,
'visit_dt_after_coord_day': False,
'last_visit': visits.get(cid),
'last_visit_day': _local_date(visits.get(cid), tz),
}
if coord is not None:
for _cid, _aid, adt, fdt, created in done:
if _aware(created, tz) > coord['created']:
fact['visit_after_coord_created'] = True
eff = effective(adt, fdt)
if eff is not None and fact['coord_day'] is not None \
and eff.astimezone(tz).date() > fact['coord_day']:
fact['visit_dt_after_coord_day'] = True
return fact
# ---------------- ۱) نمای missed ----------------
print('\n=== ۱) ?view=today&missed=1 (پیگیری‌های عقب‌افتاده) ===')
missed_ids = [cid for cid in overdue if cid in customer_rows
and customer_rows[cid][2] and not customer_rows[cid][3]
and customer_rows[cid][1]]
print('تعداد ردیف: %d' % len(missed_ids))
reasons = Counter()
samples = []
facts = {}
for cid in missed_ids:
fact = facts[cid] = analyse(cid)
if fact['visits_total'] == 0:
reasons['بدون هیچ بازدید انجام‌شده'] += 1
elif fact['visit_after_coord_created']:
reasons['بازدید انجام‌شده پس از ثبت هماهنگی'] += 1
elif fact['visit_dt_after_coord_day']:
reasons['تاریخ بازدید بعد از موعد هماهنگی است'] += 1
else:
reasons['بازدید انجام‌شده اما قدیمی‌تر از هماهنگی'] += 1
if len(samples) < args.samples and fact['visits_total']:
samples.append((cid, customer_rows[cid][1], fact))
for label, count in reasons.most_common():
print(' - %-60s %6d (%s%%)' % (label, count, _pct(count, len(missed_ids))))
with_visit = sum(1 for f in facts.values() if f['visits_total'])
after_coord = sum(1 for f in facts.values() if f['visit_after_coord_created'])
last_visit_after = sum(
1 for f in facts.values()
if f['last_visit_day'] and f['coord_day'] and f['last_visit_day'] > f['coord_day']
)
print(' => دارای حداقل یک بازدید انجام‌شده: %d (%s%%)'
% (with_visit, _pct(with_visit, len(missed_ids))))
print(' => بازدید پس از ثبت هماهنگی: %d (%s%%)'
% (after_coord, _pct(after_coord, len(missed_ids))))
print(' => آخرین بازدید بعد از موعدِ هماهنگی: %d (%s%%)'
% (last_visit_after, _pct(last_visit_after, len(missed_ids))))
statuses = Counter(f['coord_status'] for f in facts.values())
print(' => وضعیت آخرین هماهنگی: %s' % dict(statuses))
finish_without_hold = sum(
1 for cid in missed_ids
if facts[cid]['coord_status'] == Action.Status.FINISH
and not (customer_rows[cid][4] and customer_rows[cid][4] > now)
)
print(' => هماهنگیِ finish بدون قرار دستیِ فعال: %d' % finish_without_hold)
pending_old = sum(1 for cid in missed_ids
if any(row[0] == cid for row in pending_rows))
print(' => دارای بازدید در انتظارِ قدیمی (قرار منقضی): %d' % pending_old)
holds_in_missed = sum(1 for cid in missed_ids
if customer_rows[cid][4] and customer_rows[cid][4] > now)
print(' => دارای «قرار دستی» آینده و باز هم در missed: %d' % holds_in_missed)
valid_holds_in_missed = sum(
1 for cid in missed_ids if SOURCE_MANUAL in (sources.get(cid) or {}))
print(' => دارای «قرار دستی» **معتبر** و باز هم در missed: %d'
% valid_holds_in_missed)
for cid, name, fact in samples:
print(' نمونه #%s %s | موعد هماهنگی: %s | وضعیت هماهنگی: %s | آخرین بازدید: %s'
% (cid, name, fact['coord_day'], fact['coord_status'], fact['last_visit_day']))
# ---------------- ۲) نمای today ----------------
print('\n=== ۲) ?view=today (نیازمند پیگیری امروز) ===')
eligible_today = [cid for cid in today_followups if cid in customer_rows
and customer_rows[cid][2] and not customer_rows[cid][3]
and customer_rows[cid][1]]
print('تعداد ردیف: %d' % len(eligible_today))
combo = Counter()
for cid in eligible_today:
entry = dict(sources.get(cid) or {})
entry.update(today_followups.get(cid) or {})
combo[tuple(sorted(entry))] += 1
print('ترکیب منابعِ «قرار» در این نما:')
for keys, count in combo.most_common():
print(' - %-45s %6d' % ('+'.join(keys) or 'بدون منبع', count))
today_facts = [analyse(cid) for cid in eligible_today]
done_today = sum(1 for f in today_facts if f['visits_total'])
done_after = sum(1 for f in today_facts if f['visit_after_coord_created'])
def followup_day(cid):
entry = today_followups.get(cid) or {}
return _local_date(min(entry.values()), tz) if entry else None
last_after = sum(
1 for cid, f in zip(eligible_today, today_facts)
if f['last_visit_day'] and followup_day(cid)
and f['last_visit_day'] >= followup_day(cid)
)
print(' => دارای بازدید انجام‌شده: %d (%s%%)'
% (done_today, _pct(done_today, len(eligible_today))))
print(' => بازدید پس از ثبت هماهنگی: %d (%s%%)'
% (done_after, _pct(done_after, len(eligible_today))))
print(' => آخرین بازدید در/بعد از همان روزِ موعد: %d' % last_after)
for cid, fact in list(zip(eligible_today, today_facts))[:args.samples or 0]:
print(' #%-6s %-28s موعد=%-12s آخرین بازدید=%-12s منابع=%s'
% (cid, customer_rows[cid][1], followup_day(cid),
fact['last_visit_day'], sorted(today_followups.get(cid) or {})))
# تفکیک موعدهای امروز: هماهنگیِ «انجام‌شده» (زمان تماس) یا قرار دستی؟
today_coord_status = Counter()
today_with_hold = 0
for cid in eligible_today:
if SOURCE_COORDINATION in (today_followups.get(cid) or {}):
coord = last_coord.get(cid)
today_coord_status[coord['status'] if coord else '?'] += 1
if SOURCE_MANUAL in (sources.get(cid) or {}):
today_with_hold += 1
print(' => وضعیت آخرین اقدام هماهنگی (ردیف‌های دارای منبع هماهنگی): %s'
% dict(today_coord_status))
print(' => از این ردیف‌ها، دارای قرار دستیِ آینده (یعنی بعداً هم می‌آید): %d'
% today_with_hold)
# ---------------- ۳) برچسب «N قرار» ----------------
print('\n=== ۳) برچسب وضعیت «N قرار» (source_count) ===')
source_count = Counter()
for cid, entry in sources.items():
if cid in customer_rows and customer_rows[cid][2]:
source_count[len(entry)] += 1
for n in sorted(source_count):
print(' %d منبع: %6d مشتری' % (n, source_count[n]))
pairs = Counter()
same_day = 0
different_day = 0
for cid, entry in sources.items():
if len(entry) > 1:
pairs['+'.join(sorted(entry))] += 1
if SOURCE_MANUAL in entry and SOURCE_COORDINATION in entry:
if _local_date(entry[SOURCE_MANUAL], tz) == _local_date(
entry[SOURCE_COORDINATION], tz):
same_day += 1
else:
different_day += 1
print(' از ترکیب manual+coordination: هم‌روز=%d | مختلف=%d'
% (same_day, different_day))
print(' ترکیب مشتریان با بیش از یک منبع:')
for keys, count in pairs.most_common():
print(' - %-45s %6d' % (keys, count))
for cid in [cid for cid, e in sources.items() if len(e) > 1][:args.samples or 0]:
print(' نمونه #%s %s: %s'
% (cid, customer_rows[cid][1],
{k: str(_local_date(v, tz)) for k, v in sources[cid].items()}))
# ---------------- ۴) ستون قرار دستی ----------------
print('\n=== ۴) ستون visit_hold_until («تا تاریخ پیگیری نشود») ===')
holds = [(cid, _aware(row[4], tz)) for cid, row in customer_rows.items() if row[4]]
future = [(cid, until) for cid, until in holds if until > now]
past = [(cid, until) for cid, until in holds if until <= now]
valid_future = sum(
1 for cid, _ in future
if SOURCE_MANUAL in (sources.get(cid) or {})
)
print('پرشده: %d | آینده: %d | گذشته (کهنه): %d | آینده و معتبر: %d'
% (len(holds), len(future), len(past), valid_future))
print(' => از %d قرارِ آینده، %d مورد بی‌اعتبار است (آخرین فعالیت هماهنگی نیست).'
% (len(future), len(future) - valid_future))
invalid_reason = Counter()
for cid, _until in future:
if cid not in last_coord:
invalid_reason['بدون اقدام هماهنگی فعال'] += 1
elif cid in visits_by_customer and any(
_aware(row[4], tz) > last_coord[cid]['created']
for row in visits_by_customer[cid]):
invalid_reason['بازدید ثبت‌شده بعد از هماهنگی'] += 1
elif SOURCE_MANUAL not in (sources.get(cid) or {}):
invalid_reason['سایر اقدام‌های بعد از هماهنگی'] += 1
print(' دلایل بی‌اعتباری: %s' % dict(invalid_reason))
# ---------------- ۵) هماهنگی ----------------
print('\n=== ۵) اقدام‌های هماهنگی ===')
print('وضعیت: %s' % dict(Counter(row[5] for row in coord_rows)))
with_hold_field = sum(1 for cid in last_coord
if customer_rows.get(cid) and customer_rows[cid][4])
print('مشتری با هماهنگی فعال و ستونِ قرار پرشده: %d' % with_hold_field)
# ---------------- ۶) خط زمانی یک مشتری ----------------
if args.customer:
cid = args.customer
row = customer_rows.get(cid)
print('\n=== ۶) خط زمانی مشتری #%s ===' % cid)
if row is None:
print(' مشتری پیدا نشد.')
else:
print(' نام: %s | فعال: %s | لغو بازدید: %s | قرار دستی: %s'
% (row[1], row[2], row[3], row[4]))
print(' در missed: %s | در today: %s | منابع: %s'
% (cid in overdue, cid in today_followups, sources.get(cid)))
for act in Action.objects.filter(customer_id=cid).order_by('createdDT'):
print(' #%-6s %-10s %-7s action=%-19s finish=%-19s created=%-19s deleted=%s'
% (act.pk, act.actionType, act.status,
_aware(act.actionDateTime, tz), _aware(act.finishDateTime, tz),
_aware(act.createdDT, tz), act.deleted_at is not None))
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python
"""صحت‌سنجیِ دو نقص گزارش‌شده دربارهٔ ``set_visit_hold`` (روی کپی دیتابیس).
سناریوها روی یک **کپی موقت** از دیتابیس اجرا می‌شوند؛ دیتابیس اصلی دست‌نخورده
می‌ماند و هیچ کد تولیدی‌ای تغییر نمی‌کند — این فقط ابزار اثبات/رد ادعاهاست.
اجرا::
.venv/Scripts/python.exe scripts/hold_write_verification.py
"""
import argparse
import datetime
import os
import shutil
import sys
import tempfile
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
target = os.path.join(target_dir, 'verify.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _new_customer(name):
from django.utils import timezone
from admin_panel.models import Customer
today = timezone.localdate()
customer = Customer.objects.create(name_family=name, address='verify')
Customer.objects.filter(pk=customer.pk).update(
installDate=today - datetime.timedelta(days=400))
customer.refresh_from_db()
return customer
def _coordination(customer, *, days, status='finish'):
from django.utils import timezone
from admin_panel.models import Action
when = timezone.now() + datetime.timedelta(days=days)
return Action.objects.create(
customer=customer, actionType=Action.ActionType.HAMAHANGI, status=status,
actionDateTime=when,
finishDateTime=timezone.now() if status == 'finish' else None,
desc='verify',
)
def _show(label, customer):
from django.utils import timezone
from admin_panel.visit_policy import manual_holds_map, appointment_sources_map
tz = timezone.get_current_timezone()
print(' %-44s column=%-18s معتبر=%-5s منابع=%s' % (
label,
customer.visit_hold_until.astimezone(tz).strftime('%Y-%m-%d')
if customer.visit_hold_until else 'None',
'بله' if customer.pk in manual_holds_map() else 'خیر',
sorted((appointment_sources_map().get(customer.pk) or {}).keys()) or '—',
))
def claim_1_silent_failure():
"""نقص ۲ — آیا `_apply_visit_hold` نتیجه را نادیده می‌گیرد؟"""
from django.utils import timezone
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold
from admin_panel.models import Action
print('\n' + '=' * 92)
print('ادعای ۱ (نقص ۲): نتیجهٔ set_visit_hold در لایهٔ فراخوان دور ریخته می‌شود')
now = timezone.now()
customer = _new_customer('نقص۲')
set_visit_hold(customer, now + datetime.timedelta(days=20))
far = customer.visit_hold_until
_show('پس از ثبت قرار ۲۰ روزه', customer)
written = set_visit_hold(customer, now + datetime.timedelta(days=5))
print(' set_visit_hold(5 روز) ⇒ %s (مقدار بولی واقعی)' % written)
customer.refresh_from_db()
print(' ستون تغییر کرد؟ %s' % ('بله' if customer.visit_hold_until != far else 'خیر'))
result = _apply_visit_hold(
customer,
{'actionType': Action.ActionType.HAMAHANGI,
'holdRaw': now + datetime.timedelta(days=5),
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
print(' _apply_visit_hold ⇒ %s' % result)
silent = result.get('reason') != 'forward_only'
print(' نتیجه: نقص ۲ %s' % ('**هنوز وجود دارد** (نتیجه گزارش نمی‌شود)'
if silent else '**رفع شد** (reason=forward_only گزارش می‌شود)'))
def claim_2_no_success_message():
"""آیا کاربر بازخورد می‌بیند؟ (پس از رفع: پیام هشدار با دلیل و تاریخ‌ها)"""
import inspect
from admin_panel.views import actions as views
print('\n' + '=' * 92)
print('بازخورد کاربر در مسیر ویرایش/ایجاد')
edit_src = inspect.getsource(views.customerEditAction)
create_src = inspect.getsource(views.customerNewAction)
helper_src = inspect.getsource(views._report_hold_result)
print(' customerEditAction نتیجهٔ قرار را گزارش می‌کند؟ %s'
% ('_report_hold_result' in edit_src))
print(' customerNewAction نتیجهٔ قرار را گزارش می‌کند؟ %s'
% ('_report_hold_result' in create_src))
print(' پیام هشدار شامل دلیل و تاریخ‌ها است؟ %s'
% ('ذخیره نشد' in helper_src))
print(' پیام موفقیتِ گمراه‌کننده وجود دارد؟ %s'
% ('messages.success' in edit_src or 'messages.success' in create_src))
def claim_3_stale_column_lock():
"""نقص ۳ — قفل شدن ستون بعد از حذف اقدام منبع."""
from django.utils import timezone
from admin_panel.visit_policy import (manual_holds_map, set_visit_hold,
soft_delete_action)
print('\n' + '=' * 92)
print('ادعای ۳ (نقص ۳): حذف اقدام منبع ستون را پاک نمی‌کند و نوشتن را قفل می‌کند')
now = timezone.now()
customer = _new_customer('نقص۳')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
customer.refresh_from_db()
_show('قرار ۶۰ روزه ثبت شد', customer)
soft_delete_action(action)
action.refresh_from_db()
customer.refresh_from_db()
print(' اقدام منبع ⇒ status=%s deleted_at=%s' % (action.status, action.deleted_at))
_show('بعد از حذف (ستون کهنه ولی پر)', customer)
print(' در manual_holds_map هست؟ %s' % (customer.pk in manual_holds_map()))
ok = set_visit_hold(customer, now + datetime.timedelta(days=10))
customer.refresh_from_db()
print(' set_visit_hold(10 روز) ⇒ %s' % ok)
_show('پس از تلاش برای تاریخ نزدیک‌تر', customer)
print(' نتیجه: قفل %s' % ('تأیید می‌شود' if ok is False else 'رد می‌شود'))
def claim_4_escape_hatch():
"""آیا مسیر عادیِ رهاسازی وجود دارد؟ (ادعا: هیچ)"""
from django.utils import timezone
from admin_panel.models import Action
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold, soft_delete_action
print('\n' + '=' * 92)
print('ادعای ۴: «هیچ مسیر عادی‌ای برای رهاسازی وجود ندارد»')
now = timezone.now()
customer = _new_customer('رهاسازی')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
soft_delete_action(action)
customer.refresh_from_db()
_show('قفل‌شده پس از حذف اقدام', customer)
# ثبت یک اقدام **غیرهماهنگی** جدید ⇒ is_new=True ⇒ clear_visit_hold
cleared = _apply_visit_hold(
customer,
{'actionType': Action.ActionType.OUT_CALL, 'holdRaw': None,
'status': Action.Status.WAITE},
status=Action.Status.WAITE, is_new=True,
)
customer.refresh_from_db()
print(' ثبت «تماس خروجی» جدید ⇒ clear_visit_hold: %s | ستون=%s'
% (cleared.get('reason') or cleared.get('ok'), customer.visit_hold_until))
ok = set_visit_hold(customer, now + datetime.timedelta(days=10))
customer.refresh_from_db()
_show('قرار ۱۰ روزه پس از رهاسازی', customer)
print(' ⇒ %s' % ('ادعا **رد** می‌شود: مسیر وجود دارد (ولی غیرشهودی و داده‌آلوده)'
if ok else 'ادعا تأیید می‌شود'))
def claim_5_edit_to_visit():
"""مسیری که در گزارش نیامده: ویرایش هماهنگی → «بازدید» ستون را پاک نمی‌کند."""
from django.utils import timezone
from admin_panel.models import Action
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold
print('\n' + '=' * 92)
print('یافتهٔ اضافی: ویرایش هماهنگی → «بازدید» ⇒ ستون می‌ماند ولی اعتبارش می‌رود')
now = timezone.now()
customer = _new_customer('ویرایش-به-بازدید')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
customer.refresh_from_db()
action.actionType = Action.ActionType.BAZDID
action.status = Action.Status.FINISH
action.finishDateTime = now
action.save()
_apply_visit_hold(
customer,
{'actionType': Action.ActionType.BAZDID, 'holdRaw': None,
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
customer.refresh_from_db()
_show('پس از ویرایش به بازدید', customer)
ok = set_visit_hold(customer, now + datetime.timedelta(days=7))
print(' set_visit_hold(7 روز) ⇒ %s' % ok)
print(' ⇒ %s' % ('قفل، حتی با ستونِ بی‌اعتبار — شدت نقص ۳ بیشتر از تخمین گزارش است'
if ok is False else 'قفل نشد'))
def real_db_stats():
"""آمار واقعی: چند مشتری ستون پرشدهٔ «بی‌اعتبار» دارند؟"""
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import manual_holds_map
print('\n' + '=' * 92)
print('آمار دیتابیس واقعی (روی کپی)')
tz = timezone.get_current_timezone()
now = timezone.now()
future = list(Customer.objects.filter(visit_hold_until__gt=now))
valid = set(manual_holds_map())
dead = [c for c in future if c.pk not in valid]
print(' ستون پرشده و آینده: %d | معتبر: %d | بی‌اعتبار (قفل‌کننده): %d'
% (len(future), len(valid), len(dead)))
for c in dead[:10]:
last = Action.objects.filter(
customer=c, actionType=Action.ActionType.HAMAHANGI,
).order_by('-createdDT').first()
print(' id=%-6s idCode=%-10s hold=%-11s آخرین هماهنگی=%s (%s)' % (
c.id, c.idCode,
c.visit_hold_until.astimezone(tz).date(),
last.status if last else '—',
last.createdDT.date() if last else '—'))
print(' هماهنگی حذف/لغوشده با تاریخ آینده (کاندید قفل آینده): %d'
% Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI, status=Action.Status.CANCELL,
actionDateTime__gt=now).count())
def main():
parser = argparse.ArgumentParser(description='صحت‌سنجی نقص‌های set_visit_hold')
parser.add_argument('--skip-stats', action='store_true')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.db import connections
source_db = str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
tmp_dir = tempfile.mkdtemp(prefix='hold_verify_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
claim_1_silent_failure()
claim_2_no_success_message()
claim_3_stale_column_lock()
claim_4_escape_hatch()
claim_5_edit_to_visit()
if not args.skip_stats:
real_db_stats()
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()