371 lines
17 KiB
Python
371 lines
17 KiB
Python
#!/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()
|
||
|