Files
khatibi/scripts/entezar_action_audit.py
2026-10-22 14:01:08 +03:30

371 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()