improve bazdid 6
This commit is contained in:
1 parent
99c25a8216
commit
fa73a734a0
21 files changed
+32760
-7985
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user