#!/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()