146 lines
7.9 KiB
Python
146 lines
7.9 KiB
Python
"""Bounded-query projections use only the current evidence revision for financial totals."""
|
|
from datetime import datetime, time, timedelta
|
|
|
|
from django.urls import reverse
|
|
from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value
|
|
from django.db.models.functions import Coalesce
|
|
from django.utils import timezone
|
|
from .models import PaymentReceipt, PaymentReceiptRevision
|
|
|
|
|
|
def daily_payment_summary(receipts, notices):
|
|
"""Summarize permission-scoped, unfiltered querysets in two aggregate queries.
|
|
|
|
Submission means initial receipt creation. Final outcomes use the current
|
|
revision's finalization time, including receipts submitted on earlier days.
|
|
Superseded revisions and partial votes never count as final outcomes.
|
|
"""
|
|
local_timezone = timezone.get_default_timezone()
|
|
today = timezone.localdate(timezone=local_timezone)
|
|
start = timezone.make_aware(datetime.combine(today, time.min), local_timezone)
|
|
end = timezone.make_aware(datetime.combine(today + timedelta(days=1), time.min), local_timezone)
|
|
current = PaymentReceiptRevision.objects.filter(
|
|
receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
|
|
receipts = receipts.annotate(
|
|
summary_amount=Subquery(current.values('amount')[:1]),
|
|
summary_finalized_at=Subquery(current.values('cycle__finalized_at')[:1]))
|
|
submitted = Q(created_at__gte=start, created_at__lt=end)
|
|
finalized = Q(summary_finalized_at__gte=start, summary_finalized_at__lt=end)
|
|
approved = finalized & Q(status=PaymentReceipt.Status.APPROVED)
|
|
rejected = finalized & Q(status=PaymentReceipt.Status.REJECTED)
|
|
pending = Q(status__in=[PaymentReceipt.Status.PENDING, PaymentReceipt.Status.PARTIAL])
|
|
summary = receipts.aggregate(
|
|
submitted_count=Count('pk', filter=submitted),
|
|
submitted_amount=Sum('summary_amount', filter=submitted, default=0),
|
|
approved_count=Count('pk', filter=approved),
|
|
approved_amount=Sum('summary_amount', filter=approved, default=0),
|
|
rejected_count=Count('pk', filter=rejected),
|
|
rejected_amount=Sum('summary_amount', filter=rejected, default=0),
|
|
pending_count=Count('pk', filter=pending),
|
|
pending_amount=Sum('summary_amount', filter=pending, default=0))
|
|
summary['notice_count'] = notices.filter(created_at__gte=start, created_at__lt=end).count()
|
|
summary['date'] = today
|
|
return summary
|
|
|
|
|
|
def notice_sms_history(notice):
|
|
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
|
|
from django.db.models import Prefetch
|
|
from .models import PaymentNotificationAttempt
|
|
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
|
|
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
|
|
).order_by('-job__created_at', '-pk')
|
|
for notification in notifications:
|
|
job = notification.job
|
|
notification.manual_resend = ':resend:' in job.dedupe_key
|
|
notification.delivery_class = 'info'
|
|
if job.status != 'SENT':
|
|
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
|
|
elif not job.provider_message_id:
|
|
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
|
|
elif not job.delivery_status:
|
|
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
|
|
elif job.delivery_is_final:
|
|
notification.delivery_label = job.get_delivery_status_display()
|
|
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
|
|
elif job.delivery_status == 'PENDING':
|
|
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
|
|
else:
|
|
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
|
|
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
|
|
return notifications
|
|
|
|
|
|
def notices_with_totals(queryset):
|
|
approved = PaymentReceiptRevision.objects.filter(
|
|
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
|
|
version=F('receipt__current_version'), destination_mismatch=False
|
|
).values('receipt__notice_id').annotate(total=Sum('amount')).values('total')
|
|
return queryset.annotate(approved_total=Coalesce(Subquery(approved), Value(0), output_field=BigIntegerField()))
|
|
|
|
|
|
def receipts_with_current_values(queryset):
|
|
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
|
|
return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]),
|
|
current_depositor_name=Subquery(current.values('depositor_name')[:1]),
|
|
current_paid_date=Subquery(current.values('paid_date')[:1]))
|
|
|
|
|
|
def notice_form_data(notice, staff=False):
|
|
"""Customer-owned notice summary; account numbers stay on the notice page."""
|
|
return {'id': str(notice.pk), 'subject': notice.subject, 'amount': str(notice.amount),
|
|
'account': notice.account_id, 'deposit': notice.deposit_id,
|
|
'invoice': notice.invoice_number,
|
|
'url': reverse('staff_payments:notice_detail' if staff else 'payments:notice_detail', args=[notice.public_id])}
|
|
|
|
|
|
def receipt_review_summaries(receipts):
|
|
"""Attach current-version decisions in one query, preserving snapshot actor names."""
|
|
from .models import ReceiptDecision
|
|
receipts = list(receipts)
|
|
indexed = {receipt.pk: receipt for receipt in receipts}
|
|
for receipt in receipts:
|
|
receipt.review_decisions = []
|
|
if indexed:
|
|
decisions = ReceiptDecision.objects.filter(
|
|
cycle__revision__receipt_id__in=indexed,
|
|
cycle__revision__version=F('cycle__revision__receipt__current_version')
|
|
).select_related('cycle__revision').order_by('created_at', 'pk')
|
|
for decision in decisions:
|
|
indexed[decision.cycle.revision.receipt_id].review_decisions.append(decision)
|
|
return receipts
|
|
|
|
|
|
def attach_notice_receipts(notices, visible_receipts):
|
|
"""Only fetch linked receipts visible to the current viewer, for this page."""
|
|
notices = list(notices)
|
|
indexed = {notice.pk: notice for notice in notices}
|
|
for notice in notices:
|
|
notice.related_receipts = []
|
|
if indexed:
|
|
receipts = receipt_review_summaries(receipts_with_current_values(
|
|
visible_receipts.filter(notice_id__in=indexed)))
|
|
for receipt in receipts:
|
|
indexed[receipt.notice_id].related_receipts.append(receipt)
|
|
return notices
|
|
|
|
|
|
def recent_pending_notice_choices(form, retain_original=None):
|
|
"""Limit the visible menu, keeping server-side ownership validation unsliced.
|
|
|
|
An explicitly selected pending notice replaces the last option when needed.
|
|
A revision retains its original notice even if it has since been settled.
|
|
This does not change which notices the financial service may validate.
|
|
"""
|
|
field = form.fields['notice_id']
|
|
pending = notices_with_totals(field.queryset).filter(amount__gt=F('approved_total'))
|
|
available = list(pending.order_by('-created_at', '-pk')[:10])
|
|
selected = form['notice_id'].value()
|
|
if selected and not any(str(n.pk) == str(selected) for n in available):
|
|
source = field.queryset if str(selected) == str(retain_original) else pending
|
|
selected_notice = source.filter(pk=selected).first() if str(selected).isdecimal() and len(str(selected)) <= 19 and int(selected) <= 9223372036854775807 else None
|
|
if selected_notice:
|
|
available = [selected_notice] + available[:9]
|
|
field.widget.choices = [('', field.empty_label)] + [(n.pk, field.label_from_instance(n)) for n in available]
|
|
return available
|