diff --git a/config/django/base.py b/config/django/base.py index 91f41d89..25ab2a30 100644 --- a/config/django/base.py +++ b/config/django/base.py @@ -51,7 +51,8 @@ 'open_schools_platform.ticket_management.tickets.apps.TicketsConfig', 'open_schools_platform.organization_management.teachers.apps.TeachersConfig', 'open_schools_platform.testing.apps.TestingConfig', - 'open_schools_platform.sms.apps.SmsConfig' + 'open_schools_platform.sms.apps.SmsConfig', + 'open_schools_platform.receipt_management.receipts.apps.ReceiptsConfig' ] THIRD_PARTY_APPS = [ diff --git a/open_schools_platform/api/swagger_tags.py b/open_schools_platform/api/swagger_tags.py index c9d0a0d8..78941d28 100644 --- a/open_schools_platform/api/swagger_tags.py +++ b/open_schools_platform/api/swagger_tags.py @@ -13,3 +13,4 @@ class SwaggerTags: PHOTO_MANAGEMENT_PHOTOS = "Photos management. Photos" HISTORY_MANAGEMENT = "History management" TICKET_MANAGEMENT_TICKET = "Ticket management. Tickets" + RECEIPT_MANAGEMENT_RECEIPTS = "Receipt management. Receipts" diff --git a/open_schools_platform/api/urls.py b/open_schools_platform/api/urls.py index 639ecce0..6a0248e7 100644 --- a/open_schools_platform/api/urls.py +++ b/open_schools_platform/api/urls.py @@ -45,6 +45,10 @@ path('ticket', include(('open_schools_platform.ticket_management.tickets.urls', 'ticket'))), ] +receipt_management_urls = [ + path('receipts', include(('open_schools_platform.receipt_management.receipts.urls', 'receipts'))), +] + urlpatterns = [ path('user-management/', include((user_management_urls, 'user-management'))), path('organization-management/', include((organization_management_urls, 'organization-management'))), @@ -53,7 +57,8 @@ path('students-management/', include((students_management_urls, 'students-management'))), path('photos-management/', include((photos_management_urls, 'photo-management'))), path('history-management/', include((history_management_urls, 'history-management'))), - path('ticket-management/', include((ticket_management_urls, 'ticket-management'))) + path('ticket-management/', include((ticket_management_urls, 'ticket-management'))), + path('receipt-management/', include((receipt_management_urls, 'receipt-management'))) ] if settings.SWAGGER_ENABLED: diff --git a/open_schools_platform/receipt_management/__init__.py b/open_schools_platform/receipt_management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/open_schools_platform/receipt_management/receipts/__init__.py b/open_schools_platform/receipt_management/receipts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/open_schools_platform/receipt_management/receipts/admin.py b/open_schools_platform/receipt_management/receipts/admin.py new file mode 100644 index 00000000..d481d8eb --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/admin.py @@ -0,0 +1,105 @@ +from django.contrib import admin +from django.utils.html import format_html + +from .models import Receipt, ReceiptNotification + + +@admin.register(Receipt) +class ReceiptAdmin(admin.ModelAdmin): + list_display = [ + 'internal_receipt_number', 'recipient_full_name', 'student_profile_name', + 'institution_name', 'total_amount', 'payment_due_date', + 'created_at', 'pdf_file_link' + ] + list_filter = [ + 'payment_due_date', 'created_at', 'institution_name', + 'service_category' + ] + search_fields = [ + 'internal_receipt_number', 'recipient_full_name', 'payer_full_name', + 'institution_name', 'service_name', 'student_profile__user__first_name', + 'student_profile__user__last_name' ] + readonly_fields = [ + 'id', 'created_at', 'updated_at' + ] + fieldsets = ( + ('Student Information', { + 'fields': ('student_profile', 'recipient_full_name', 'payer_full_name') + }), + ('Receipt Details', { + 'fields': ('internal_receipt_number', 'institution_name', 'service_name', + 'service_category', 'payment_purpose') + }), + ('Financial Information', { + 'fields': ('debt_at_month_start', 'recalculation_amount', 'paid_amount', + 'debt_at_next_month_start', 'prepayment', 'service_amount', 'total_amount') + }), + ('Dates', { + 'fields': ('receipt_date', 'payment_due_date') + }), + ('Files & QR Codes', { + 'fields': ('pdf_file', 'qr_code_data') }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }) + ) + + def student_profile_name(self, obj): + """Display student name from profile""" + if obj.student_profile: + return f"{obj.student_profile.user.first_name} {obj.student_profile.user.last_name}" + return "N/A" + + student_profile_name.short_description = "Student Name" + + def pdf_file_link(self, obj): + if obj.pdf_file: + return format_html( + 'Download PDF', + obj.pdf_file.url + ) + return "No PDF" + + pdf_file_link.short_description = "PDF File" + + def get_queryset(self, request): + return super().get_queryset(request).select_related('student_profile__user') + + +@admin.register(ReceiptNotification) +class ReceiptNotificationAdmin(admin.ModelAdmin): + list_display = [ + 'id', 'receipt_number', 'notification_type', + 'is_delivered', 'sent_at', 'created_at' + ] + list_filter = [ + 'notification_type', 'is_delivered', 'sent_at', 'created_at' + ] + search_fields = [ + 'receipt__internal_receipt_number', 'receipt__recipient_full_name', + 'receipt__payer_full_name', 'receipt__institution_name' + ] + readonly_fields = ['id', 'created_at', 'updated_at', 'sent_at'] + + fieldsets = ( + ('Notification Details', { + 'fields': ('receipt', 'notification_type') + }), + ('Status', { + 'fields': ('is_delivered', 'sent_at') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }) + ) + + def receipt_number(self, obj): + """Display receipt number""" + return obj.receipt.internal_receipt_number if obj.receipt else "N/A" + + receipt_number.short_description = "Receipt Number" + + def get_queryset(self, request): + return super().get_queryset(request).select_related('receipt') diff --git a/open_schools_platform/receipt_management/receipts/apps.py b/open_schools_platform/receipt_management/receipts/apps.py new file mode 100644 index 00000000..b5f2523e --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReceiptsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'open_schools_platform.receipt_management.receipts' diff --git a/open_schools_platform/receipt_management/receipts/filters.py b/open_schools_platform/receipt_management/receipts/filters.py new file mode 100644 index 00000000..656bb1f5 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/filters.py @@ -0,0 +1,68 @@ +from django_filters import CharFilter, BooleanFilter, DateFilter, ChoiceFilter, NumberFilter + +from open_schools_platform.common.filters import BaseFilterSet, UUIDInFilter, filter_by_ids, MetaCharIContainsMixin +from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptNotification + + +class ReceiptFilter(BaseFilterSet): + ids = CharFilter(method=filter_by_ids) + or_search = CharFilter(field_name="or_search", method="OR") + + payer_full_name = CharFilter(field_name="payer_full_name", lookup_expr="icontains") + recipient_full_name = CharFilter(field_name="recipient_full_name", lookup_expr="icontains") + student_profile = UUIDInFilter(field_name="student_profile", lookup_expr="in") + + institution_name = CharFilter(field_name="institution_name", lookup_expr="icontains") + service_name = CharFilter(field_name="service_name", lookup_expr="icontains") + service_category = CharFilter(field_name="service_category", lookup_expr="icontains") + + total_amount_min = NumberFilter(field_name="total_amount", lookup_expr="gte") + total_amount_max = NumberFilter(field_name="total_amount", lookup_expr="lte") + service_amount_min = NumberFilter(field_name="service_amount", lookup_expr="gte") + service_amount_max = NumberFilter(field_name="service_amount", lookup_expr="lte") + + payment_due_date_from = DateFilter(field_name="payment_due_date", lookup_expr="gte") + payment_due_date_to = DateFilter(field_name="payment_due_date", lookup_expr="lte") + receipt_date_from = DateFilter(field_name="receipt_date", lookup_expr="gte") + receipt_date_to = DateFilter(field_name="receipt_date", lookup_expr="lte") + created_at_from = DateFilter(field_name="created_at", lookup_expr="gte") + created_at_to = DateFilter(field_name="created_at", lookup_expr="lte") + internal_receipt_number = CharFilter(field_name="internal_receipt_number", lookup_expr="icontains") + + payment_purpose = CharFilter(field_name="payment_purpose", lookup_expr="icontains") + + class Meta(MetaCharIContainsMixin): + model = Receipt + fields = [ + 'id', 'payer_full_name', 'recipient_full_name', 'student_profile', + 'institution_name', 'service_name', 'service_category', + 'total_amount', 'service_amount', 'payment_due_date', 'receipt_date', + 'internal_receipt_number', 'payment_purpose', + 'created_at', 'updated_at' + ] + + +class ReceiptNotificationFilter(BaseFilterSet): + or_search = CharFilter(field_name="or_search", method="OR") + + receipt_recipient_name = CharFilter(field_name="receipt__recipient_full_name", lookup_expr="icontains") + receipt_institution = CharFilter(field_name="receipt__institution_name", lookup_expr="icontains") + receipt_service = CharFilter(field_name="receipt__service_name", lookup_expr="icontains") + + notification_type = ChoiceFilter( + field_name="notification_type", + choices=ReceiptNotification.NOTIFICATION_TYPES + ) + is_delivered = BooleanFilter(field_name="is_delivered") + + created_at_from = DateFilter(field_name="created_at", lookup_expr="gte") + created_at_to = DateFilter(field_name="created_at", lookup_expr="lte") + sent_at_from = DateFilter(field_name="sent_at", lookup_expr="gte") + sent_at_to = DateFilter(field_name="sent_at", lookup_expr="lte") + + class Meta(MetaCharIContainsMixin): + model = ReceiptNotification + fields = [ + 'id', 'receipt', 'notification_type', 'is_delivered', + 'created_at', 'updated_at', 'sent_at' + ] diff --git a/open_schools_platform/receipt_management/receipts/migrations/0001_initial.py b/open_schools_platform/receipt_management/receipts/migrations/0001_initial.py new file mode 100644 index 00000000..0ea21979 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/migrations/0001_initial.py @@ -0,0 +1,225 @@ +# Generated by Django 3.2.12 on 2025-05-23 17:28 + +import uuid +from decimal import Decimal + +import django.core.validators +import django.utils.timezone +import rules.contrib.models +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('students', '0007_historicalstudent'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Receipt', + fields=[ + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('internal_receipt_number', + models.CharField(help_text='Уникальный идентификатор платёжной записи внутри учётной системы', + max_length=100)), + ('payer_full_name', + models.CharField(help_text='ФИО физического лица, производящего оплату', max_length=300)), + ('recipient_full_name', + models.CharField(help_text='ФИО обучающегося / лица, за которого осуществляется платёж', + max_length=300)), + ('institution_name', + models.CharField(help_text='Учебное учреждение, оказывающее услугу', max_length=500)), + ('service_name', + models.CharField(help_text='Наименование конкретной оплачиваемой услуги', max_length=300)), + ('service_category', + models.CharField(blank=True, help_text='Группировка или категория услуги', max_length=200)), + ('debt_at_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Остаток долга на первое число текущего месяца', + max_digits=10)), + ('recalculation_amount', + models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Изменение начислений', + max_digits=10)), + ('paid_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Сколько уже заплатили за услугу', max_digits=10)), + ('debt_at_next_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Остаток долга на начало следующего месяца', + max_digits=10)), + ('prepayment', + models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Деньги, внесенные вперед', + max_digits=10)), + ('service_amount', models.DecimalField(decimal_places=2, + help_text='Фактическая стоимость конкретной услуги за расчетный период', + max_digits=10, validators=[ + django.core.validators.MinValueValidator(Decimal('0.00'))])), + ('total_amount', + models.DecimalField(decimal_places=2, help_text='Конечный итог суммы к оплате', max_digits=10, + validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])), + ('receipt_date', models.DateField(help_text='Дата создания квитанции')), + ('payment_due_date', models.DateField(help_text='Крайний срок оплаты')), + ('payment_purpose', models.TextField(help_text='Показывает основание для оплаты')), + ('qr_code_data', models.TextField(blank=True, + help_text='QR-код данные для быстрой оплаты через банковские приложения')), + ('barcode_data', + models.TextField(blank=True, help_text='Штрихкодовая строка для автоматического считывания')), + ('is_viewed', models.BooleanField(default=False, help_text='Была ли просмотрена квитанция родителем')), + ('viewed_at', models.DateTimeField(blank=True, help_text='Время просмотра квитанции', null=True)), + ('pdf_file', + models.FileField(blank=True, help_text='PDF файл квитанции', null=True, upload_to='receipts/pdfs/')), + ('student_profile', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', + to='students.studentprofile')), + ], + options={ + 'ordering': ['-created_at'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name='ReceiptNotification', + fields=[ + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('notification_type', models.CharField( + choices=[('initial', 'Initial Receipt Notification'), ('reminder', 'Payment Reminder'), + ('overdue', 'Overdue Payment Notice')], max_length=20)), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ('is_delivered', models.BooleanField(default=False)), + ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', + to='receipts.receipt')), + ], + options={ + 'ordering': ['-sent_at'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name='HistoricalReceiptNotification', + fields=[ + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), + ('notification_type', models.CharField( + choices=[('initial', 'Initial Receipt Notification'), ('reminder', 'Payment Reminder'), + ('overdue', 'Overdue Payment Notice')], max_length=20)), + ('sent_at', models.DateTimeField(blank=True, editable=False)), + ('is_delivered', models.BooleanField(default=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', + models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', + to=settings.AUTH_USER_MODEL)), + ('receipt', models.ForeignKey(blank=True, db_constraint=False, null=True, + on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', + to='receipts.receipt')), + ], + options={ + 'verbose_name': 'historical receipt notification', + 'verbose_name_plural': 'historical receipt notifications', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalReceipt', + fields=[ + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), + ('internal_receipt_number', + models.CharField(help_text='Уникальный идентификатор платёжной записи внутри учётной системы', + max_length=100)), + ('payer_full_name', + models.CharField(help_text='ФИО физического лица, производящего оплату', max_length=300)), + ('recipient_full_name', + models.CharField(help_text='ФИО обучающегося / лица, за которого осуществляется платёж', + max_length=300)), + ('institution_name', + models.CharField(help_text='Учебное учреждение, оказывающее услугу', max_length=500)), + ('service_name', + models.CharField(help_text='Наименование конкретной оплачиваемой услуги', max_length=300)), + ('service_category', + models.CharField(blank=True, help_text='Группировка или категория услуги', max_length=200)), + ('debt_at_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Остаток долга на первое число текущего месяца', + max_digits=10)), + ('recalculation_amount', + models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Изменение начислений', + max_digits=10)), + ('paid_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Сколько уже заплатили за услугу', max_digits=10)), + ('debt_at_next_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Остаток долга на начало следующего месяца', + max_digits=10)), + ('prepayment', + models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Деньги, внесенные вперед', + max_digits=10)), + ('service_amount', models.DecimalField(decimal_places=2, + help_text='Фактическая стоимость конкретной услуги за расчетный период', + max_digits=10, validators=[ + django.core.validators.MinValueValidator(Decimal('0.00'))])), + ('total_amount', + models.DecimalField(decimal_places=2, help_text='Конечный итог суммы к оплате', max_digits=10, + validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])), + ('receipt_date', models.DateField(help_text='Дата создания квитанции')), + ('payment_due_date', models.DateField(help_text='Крайний срок оплаты')), + ('payment_purpose', models.TextField(help_text='Показывает основание для оплаты')), + ('qr_code_data', models.TextField(blank=True, + help_text='QR-код данные для быстрой оплаты через банковские приложения')), + ('barcode_data', + models.TextField(blank=True, help_text='Штрихкодовая строка для автоматического считывания')), + ('is_viewed', models.BooleanField(default=False, help_text='Была ли просмотрена квитанция родителем')), + ('viewed_at', models.DateTimeField(blank=True, help_text='Время просмотра квитанции', null=True)), + ('pdf_file', models.TextField(blank=True, help_text='PDF файл квитанции', max_length=100, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', + models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', + to=settings.AUTH_USER_MODEL)), + ('student_profile', models.ForeignKey(blank=True, db_constraint=False, null=True, + on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', + to='students.studentprofile')), + ], + options={ + 'verbose_name': 'historical receipt', + 'verbose_name_plural': 'historical receipts', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddIndex( + model_name='receipt', + index=models.Index(fields=['student_profile', '-created_at'], name='receipts_re_student_10a417_idx'), + ), + migrations.AddIndex( + model_name='receipt', + index=models.Index(fields=['payment_due_date'], name='receipts_re_payment_8f28a9_idx'), + ), + migrations.AddIndex( + model_name='receipt', + index=models.Index(fields=['is_viewed'], name='receipts_re_is_view_034632_idx'), + ), + ] diff --git a/open_schools_platform/receipt_management/receipts/migrations/0002_auto_20250524_2242.py b/open_schools_platform/receipt_management/receipts/migrations/0002_auto_20250524_2242.py new file mode 100644 index 00000000..11e4e0b5 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/migrations/0002_auto_20250524_2242.py @@ -0,0 +1,66 @@ +# Generated by Django 3.2.12 on 2025-05-24 17:42 + +import uuid +from decimal import Decimal + +import django.utils.timezone +import rules.contrib.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('receipts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='historicalreceipt', + name='charged_this_month', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Сумма, начисленная за текущий месяц', max_digits=10), + ), + migrations.AddField( + model_name='receipt', + name='charged_this_month', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Сумма, начисленная за текущий месяц', max_digits=10), + ), + migrations.CreateModel( + name='ReceiptService', + fields=[ + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('institution', models.CharField(help_text='Institution code/name for this service', max_length=200)), + ('service_name', models.CharField(help_text='Name of the specific service', max_length=300)), + ('debt_at_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Debt at start of month for this service', + max_digits=10)), + ('charged_this_month', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Amount charged this month for this service', + max_digits=10)), + ('recalculation_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Recalculation amount for this service', + max_digits=10)), + ('paid_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Amount paid for this service', max_digits=10)), + ('debt_at_next_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Debt at start of next month for this service', + max_digits=10)), + ('prepayment', + models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Prepayment for this service', + max_digits=10)), + ('service_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Total amount due for this service', max_digits=10)), + ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', + to='receipts.receipt')), + ], + options={ + 'ordering': ['service_name'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/open_schools_platform/receipt_management/receipts/migrations/0003_auto_20250524_2304.py b/open_schools_platform/receipt_management/receipts/migrations/0003_auto_20250524_2304.py new file mode 100644 index 00000000..4a43caa9 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/migrations/0003_auto_20250524_2304.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2025-05-24 18:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('receipts', '0002_auto_20250524_2242'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalreceipt', + name='barcode_data', + ), + migrations.RemoveField( + model_name='receipt', + name='barcode_data', + ), + ] diff --git a/open_schools_platform/receipt_management/receipts/migrations/0004_auto_20250526_1221.py b/open_schools_platform/receipt_management/receipts/migrations/0004_auto_20250526_1221.py new file mode 100644 index 00000000..0b29e047 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/migrations/0004_auto_20250526_1221.py @@ -0,0 +1,66 @@ +# Generated by Django 3.2.12 on 2025-05-26 07:21 + +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('receipts', '0003_auto_20250524_2304'), + ] + + operations = [ + migrations.AlterField( + model_name='receiptservice', + name='charged_this_month', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Сумма, взимаемая в этом месяце за кружок', max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='debt_at_month_start', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Долг на начало месяца для этого кружка', max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='debt_at_next_month_start', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Долг на начало следующего месяца за кружок', max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='institution', + field=models.CharField(help_text='Учебное учреждение', max_length=200), + ), + migrations.AlterField( + model_name='receiptservice', + name='paid_amount', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Сколько оплачено за кружок', + max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='prepayment', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Предоплата за кружок', + max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='recalculation_amount', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Перерасчет в этом месяце за кружок', max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='service_amount', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), + help_text='Общая сумма долга за кружок', max_digits=10), + ), + migrations.AlterField( + model_name='receiptservice', + name='service_name', + field=models.CharField(help_text='Название кружка', max_length=300), + ), + ] diff --git a/open_schools_platform/receipt_management/receipts/migrations/__init__.py b/open_schools_platform/receipt_management/receipts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/open_schools_platform/receipt_management/receipts/models.py b/open_schools_platform/receipt_management/receipts/models.py new file mode 100644 index 00000000..6bcc60b7 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/models.py @@ -0,0 +1,267 @@ +import uuid +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models +from simple_history.models import HistoricalRecords + +from open_schools_platform.common.models import BaseModel, BaseManager +from open_schools_platform.student_management.students.models import StudentProfile + + +class ReceiptManager(BaseManager): + def create_receipt(self, student_profile: StudentProfile, **kwargs): + receipt = self.model( + student_profile=student_profile, + **kwargs + ) + receipt.full_clean() + receipt.save(using=self.db) + return receipt + + +class Receipt(BaseModel): + """ + Main receipt model based on the field analysis. + Contains all essential receipt information for the MVP. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + + student_profile = models.ForeignKey( + StudentProfile, + on_delete=models.CASCADE, + related_name="receipts" + ) + + internal_receipt_number = models.CharField( + max_length=100, + help_text="Уникальный идентификатор платёжной записи внутри учётной системы" + ) + + payer_full_name = models.CharField( + max_length=300, + help_text="ФИО физического лица, производящего оплату" + ) + recipient_full_name = models.CharField( + max_length=300, + help_text="ФИО обучающегося / лица, за которого осуществляется платёж" + ) + + institution_name = models.CharField( + max_length=500, + help_text="Учебное учреждение, оказывающее услугу" + ) + + service_name = models.CharField( + max_length=300, + help_text="Наименование конкретной оплачиваемой услуги" + ) + service_category = models.CharField( + max_length=200, + blank=True, + help_text="Группировка или категория услуги" + ) + + debt_at_month_start = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Остаток долга на первое число текущего месяца" + ) + charged_this_month = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Сумма, начисленная за текущий месяц" + ) + recalculation_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Изменение начислений" + ) + paid_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Сколько уже заплатили за услугу" + ) + debt_at_next_month_start = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Остаток долга на начало следующего месяца" + ) + prepayment = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Деньги, внесенные вперед" + ) + service_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(Decimal('0.00'))], + help_text="Фактическая стоимость конкретной услуги за расчетный период" + ) + total_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(Decimal('0.00'))], + help_text="Конечный итог суммы к оплате" + ) + + receipt_date = models.DateField( + help_text="Дата создания квитанции" + ) + payment_due_date = models.DateField( + help_text="Крайний срок оплаты" + ) + + payment_purpose = models.TextField( + help_text="Показывает основание для оплаты" + ) + qr_code_data = models.TextField( + blank=True, + help_text="QR-код данные для быстрой оплаты через банковские приложения" + ) + + pdf_file = models.FileField( + upload_to='receipts/pdfs/', + null=True, + blank=True, + help_text="PDF файл квитанции" + ) + + history = HistoricalRecords() + objects = ReceiptManager() + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['student_profile', '-created_at']), + models.Index(fields=['payment_due_date']), + ] + + def __str__(self): + return f"Receipt {self.internal_receipt_number} for {self.recipient_full_name}" + + @property + def is_overdue(self): + """Check if receipt payment is overdue""" + from django.utils import timezone + return timezone.now().date() > self.payment_due_date and self.total_amount > 0 + + +class ReceiptNotificationManager(BaseManager): + def create_notification(self, receipt: Receipt, notification_type: str): + notification = self.model( + receipt=receipt, + notification_type=notification_type + ) + notification.full_clean() + notification.save(using=self.db) + return notification + + +class ReceiptNotification(BaseModel): + """ + Track notifications sent for receipts + """ + NOTIFICATION_TYPES = [ + ('initial', 'Initial Receipt Notification'), + ('reminder', 'Payment Reminder'), + ('overdue', 'Overdue Payment Notice'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + receipt = models.ForeignKey( + Receipt, + on_delete=models.CASCADE, + related_name="notifications" + ) + notification_type = models.CharField( + max_length=20, + choices=NOTIFICATION_TYPES + ) + sent_at = models.DateTimeField(auto_now_add=True) + is_delivered = models.BooleanField(default=False) + + objects = ReceiptNotificationManager() + history = HistoricalRecords() + + class Meta: + ordering = ['-sent_at'] + + def __str__(self): + return f"{self.notification_type} notification for {self.receipt}" + + +class ReceiptService(BaseModel): + """ + Individual service within a receipt - for detailed breakdown + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + receipt = models.ForeignKey( + Receipt, + on_delete=models.CASCADE, + related_name="services" + ) + + institution = models.CharField( + max_length=200, + help_text="Учебное учреждение" + ) + service_name = models.CharField( + max_length=300, + help_text="Название кружка" + ) + + debt_at_month_start = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Долг на начало месяца для этого кружка" + ) + charged_this_month = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Сумма, взимаемая в этом месяце за кружок" + ) + recalculation_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Перерасчет в этом месяце за кружок" + ) + paid_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Сколько оплачено за кружок" + ) + debt_at_next_month_start = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Долг на начало следующего месяца за кружок" + ) + prepayment = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Предоплата за кружок" + ) + service_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="Общая сумма долга за кружок" + ) + + class Meta: + ordering = ['service_name'] + + def __str__(self): + return f"{self.service_name} - {self.service_amount}" diff --git a/open_schools_platform/receipt_management/receipts/selectors.py b/open_schools_platform/receipt_management/receipts/selectors.py new file mode 100644 index 00000000..9bdbd136 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/selectors.py @@ -0,0 +1,241 @@ +from typing import Dict, Optional + +from django.db.models import QuerySet +from rest_framework.exceptions import PermissionDenied + +from open_schools_platform.common.selectors import selector_factory +from open_schools_platform.parent_management.families.models import Family +from open_schools_platform.receipt_management.receipts.filters import ReceiptFilter, ReceiptNotificationFilter +from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptNotification +from open_schools_platform.student_management.students.models import StudentProfile +from open_schools_platform.user_management.users.models import User + + +@selector_factory(Receipt) +def get_receipts(*, filters=None, prefetch_related_list=None) -> QuerySet: + """ + Get receipts with filtering support. + """ + filters = filters or {} + prefetch_related_list = prefetch_related_list or [] + + qs = Receipt.objects.prefetch_related(*prefetch_related_list).all() + receipts = ReceiptFilter(filters, qs).qs + + return receipts + + +@selector_factory(Receipt) +def get_receipt(*, filters=None, user: User = None, prefetch_related_list=None) -> Receipt: + """ + Get a single receipt with permission checks. + """ + filters = filters or {} + prefetch_related_list = prefetch_related_list or [] + + qs = Receipt.objects.prefetch_related(*prefetch_related_list).all() + receipt = ReceiptFilter(filters, qs).qs.first() + + if user and receipt and not user.has_perm("receipts.receipt_access", receipt): + raise PermissionDenied + + return receipt + + +@selector_factory(ReceiptNotification) +def get_receipt_notifications(*, filters=None, prefetch_related_list=None) -> QuerySet: + """ + Get receipt notifications with filtering support. + """ + filters = filters or {} + prefetch_related_list = prefetch_related_list or [] + + qs = ReceiptNotification.objects.prefetch_related(*prefetch_related_list).all() + notifications = ReceiptNotificationFilter(filters, qs).qs + + return notifications.order_by('-sent_at') + +def get_overdue_receipts() -> QuerySet: + """ + Get all overdue receipts for system-wide processing. + """ + from django.utils import timezone + + return Receipt.objects.filter( + payment_due_date__lt=timezone.now().date(), + total_amount__gt=0 + ).order_by('payment_due_date') + + +def get_receipts_for_family(family: Family, filters: Dict = None) -> QuerySet[Receipt]: + """ + Get receipts for all students in a family + """ + filters = filters or {} + + student_profiles = family.student_profiles.all() + qs = Receipt.objects.filter(student_profile__in=student_profiles) + + if filters: + qs = qs.filter(**filters) + + return qs.order_by('-created_at') + + +def get_receipts_for_student_profile(student_profile: StudentProfile, filters: Dict = None) -> QuerySet[Receipt]: + """ + Get receipts for a specific student profile + """ + filters = filters or {} + + qs = Receipt.objects.filter(student_profile=student_profile) + + if filters: + qs = qs.filter(**filters) + + return qs.order_by('-created_at') + + +def get_overdue_receipts_for_family(family: Family) -> QuerySet[Receipt]: + """ + Get overdue receipts for a family + """ + from django.utils import timezone + today = timezone.now().date() + + return get_receipts_for_family(family, { + 'payment_due_date__lt': today, + 'total_amount__gt': 0 + }) + + +def get_recent_receipts_for_family(family: Family, days: int = 30) -> QuerySet[Receipt]: + """ + Get recent receipts for a family + """ + from django.utils import timezone + from datetime import timedelta + + cutoff_date = timezone.now() - timedelta(days=days) + + return get_receipts_for_family(family, { + 'created_at__gte': cutoff_date + }) + + +@selector_factory(Receipt) +def get_receipts_with_filters(user: User, filters: Dict = None, prefetch_related_list=None) -> QuerySet: + filters = filters or {} + prefetch_related_list = prefetch_related_list or [] + + qs = Receipt.objects.prefetch_related(*prefetch_related_list).all() + + receipts = ReceiptFilter(filters, qs).qs + + return receipts.order_by('-created_at') + + +def get_single_receipt_notification(*, filters: Dict = None, empty_exception=False) -> Optional[ReceiptNotification]: + """ + Get single receipt notification by filters + """ + filters = filters or {} + + try: + return ReceiptNotification.objects.get(**filters) + except ReceiptNotification.DoesNotExist: + if empty_exception: + from open_schools_platform.errors.exceptions import NotFound + raise NotFound("Receipt notification not found") + return None + + +def get_notifications_list(*, filters: Dict = None) -> QuerySet[ReceiptNotification]: + """ + Get receipt notifications by filters + """ + filters = filters or {} + + qs = ReceiptNotification.objects.all() + + if filters: + qs = qs.filter(**filters) + + return qs.order_by('-sent_at') + + +def get_notifications_for_receipt(receipt: Receipt) -> QuerySet[ReceiptNotification]: + """ + Get all notifications for a specific receipt + """ + return ReceiptNotification.objects.filter(receipt=receipt).order_by('-sent_at') + + +def get_recent_notifications(days: int = 7) -> QuerySet[ReceiptNotification]: + """ + Get notifications from the last N days + """ + from django.utils import timezone + from datetime import timedelta + + cutoff_date = timezone.now() - timedelta(days=days) + + return ReceiptNotification.objects.filter( + sent_at__gte=cutoff_date + ).order_by('-sent_at') + + +def get_failed_notifications() -> QuerySet[ReceiptNotification]: + """ + Get notifications that failed to deliver + """ + return ReceiptNotification.objects.filter(is_delivered=False) + + +def get_receipt_statistics_for_family(family: Family) -> Dict: + """ + Get receipt statistics for a family + """ + receipts = get_receipts_for_family(family) + total_receipts = receipts.count() + overdue_receipts = get_overdue_receipts_for_family(family).count() + + total_amount = sum(receipt.total_amount for receipt in receipts) + overdue_amount = sum(receipt.total_amount for receipt in get_overdue_receipts_for_family(family)) + + return { + 'total_receipts': total_receipts, + 'overdue_receipts': overdue_receipts, + 'total_amount': total_amount, + 'overdue_amount': overdue_amount, + } + + +def get_receipt_statistics_for_student(student_profile: StudentProfile) -> Dict: + """ + Get receipt statistics for a student + """ + receipts = get_receipts_for_student_profile(student_profile) + total_receipts = receipts.count() + + from django.utils import timezone + today = timezone.now().date() + overdue_receipts = receipts.filter( + payment_due_date__lt=today, + total_amount__gt=0 + ).count() + + total_amount = sum(receipt.total_amount for receipt in receipts) + overdue_amount = sum( + receipt.total_amount for receipt in receipts.filter( + payment_due_date__lt=today, + total_amount__gt=0 + ) + ) + + return { + 'total_receipts': total_receipts, + 'overdue_receipts': overdue_receipts, + 'total_amount': total_amount, + 'overdue_amount': overdue_amount, + } diff --git a/open_schools_platform/receipt_management/receipts/serializers.py b/open_schools_platform/receipt_management/receipts/serializers.py new file mode 100644 index 00000000..acd12b22 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/serializers.py @@ -0,0 +1,182 @@ +from django.utils import timezone +from rest_framework import serializers + +from open_schools_platform.common.serializers import BaseModelSerializer +from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptNotification, ReceiptService +from open_schools_platform.student_management.students.serializers import GetStudentProfileSerializer + + +class ReceiptServiceSerializer(BaseModelSerializer): + """Serializer for individual receipt services""" + + class Meta: + model = ReceiptService + fields = [ + 'id', 'institution', 'service_name', 'debt_at_month_start', + 'charged_this_month', 'recalculation_amount', 'paid_amount', + 'debt_at_next_month_start', 'prepayment', 'service_amount' + ] + + +class CreateReceiptSerializer(BaseModelSerializer): + """Serializer for creating receipts - used by dispatchers""" + + class Meta: + model = Receipt + fields = [ + 'student_profile', 'internal_receipt_number', 'payer_full_name', + 'recipient_full_name', 'institution_name', 'service_name', + 'service_category', 'debt_at_month_start', 'charged_this_month', 'recalculation_amount', + 'paid_amount', 'debt_at_next_month_start', 'prepayment', + 'service_amount', 'total_amount', 'receipt_date', 'payment_due_date', + 'payment_purpose', 'qr_code_data', 'pdf_file' + ] + + +class UpdateReceiptSerializer(serializers.Serializer): + """Serializer for updating receipts""" + + internal_receipt_number = serializers.CharField(max_length=100, required=False) + payer_full_name = serializers.CharField(max_length=300, required=False) + recipient_full_name = serializers.CharField(max_length=300, required=False) + institution_name = serializers.CharField(max_length=500, required=False) + service_name = serializers.CharField(max_length=300, required=False) + service_category = serializers.CharField(max_length=200, required=False, allow_blank=True) + debt_at_month_start = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + charged_this_month = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + recalculation_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + paid_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + debt_at_next_month_start = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + prepayment = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + service_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + total_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + receipt_date = serializers.DateField(required=False) + payment_due_date = serializers.DateField(required=False) + payment_purpose = serializers.CharField(required=False) + qr_code_data = serializers.CharField(required=False, allow_blank=True) + pdf_file = serializers.FileField(required=False) + + +class GetReceiptSerializer(BaseModelSerializer): + """Serializer for getting receipt details - used by parents mobile app""" + + student_profile = GetStudentProfileSerializer(read_only=True) + is_overdue = serializers.ReadOnlyField() + services = ReceiptServiceSerializer(many=True, read_only=True) + + class Meta: + model = Receipt + fields = [ + 'id', 'student_profile', 'internal_receipt_number', 'payer_full_name', 'recipient_full_name', + 'institution_name', 'service_name', + 'service_category', 'service_amount', 'total_amount', 'receipt_date', + 'payment_due_date', 'payment_purpose', 'qr_code_data', + 'is_overdue', 'pdf_file', 'services', 'created_at' + ] + + +class GetReceiptListSerializer(BaseModelSerializer): + """Serializer for receipt list - simplified view for mobile app""" + + is_overdue = serializers.ReadOnlyField() + + class Meta: + model = Receipt + fields = [ + 'id', 'recipient_full_name', 'payer_full_name', 'institution_name', 'service_name', + 'total_amount', 'receipt_date', 'payment_due_date', + 'is_overdue', 'created_at' + ] + + +class GetReceiptDetailedSerializer(BaseModelSerializer): + """Serializer for detailed receipt view - used by dispatchers""" + + student_profile = GetStudentProfileSerializer(read_only=True) + is_overdue = serializers.ReadOnlyField() + notifications_count = serializers.SerializerMethodField() + services = ReceiptServiceSerializer(many=True, read_only=True) + + def get_notifications_count(self, obj): + return obj.notifications.count() + + class Meta: + model = Receipt + fields = [ + 'id', 'student_profile', 'internal_receipt_number', 'payer_full_name', + 'recipient_full_name', 'institution_name', 'service_name', 'service_category', 'debt_at_month_start', + 'charged_this_month', 'recalculation_amount', + 'paid_amount', 'debt_at_next_month_start', 'prepayment', + 'service_amount', 'total_amount', 'receipt_date', 'payment_due_date', + 'payment_purpose', 'qr_code_data', + 'is_overdue', 'pdf_file', 'notifications_count', 'services', 'created_at'] + + +class UploadReceiptPDFSerializer(serializers.Serializer): + """Serializer for PDF file upload and parsing""" + + pdf_file = serializers.FileField() + student_profile_id = serializers.UUIDField(required=False) + + def validate_pdf_file(self, value): + if not value.name.endswith('.pdf'): + raise serializers.ValidationError("File must be a PDF") + + # Check file size (max 10MB) + if value.size > 10 * 1024 * 1024: + raise serializers.ValidationError("File size must be less than 10MB") + + return value + + +class BulkReceiptUploadSerializer(serializers.Serializer): + """Serializer for bulk receipt upload""" + + pdf_files = serializers.ListField( + child=serializers.FileField(), + min_length=1, + max_length=50 + ) + + def validate_pdf_files(self, value): + for file in value: + if not file.name.endswith('.pdf'): + raise serializers.ValidationError(f"File {file.name} must be a PDF") + + if file.size > 10 * 1024 * 1024: + raise serializers.ValidationError(f"File {file.name} size must be less than 10MB") + + return value + + +class SendReceiptNotificationSerializer(serializers.Serializer): + """Serializer for sending receipt notifications""" + + receipt_ids = serializers.ListField( + child=serializers.UUIDField(), + min_length=1 + ) + notification_type = serializers.ChoiceField( + choices=ReceiptNotification.NOTIFICATION_TYPES, + default='initial' + ) + custom_message = serializers.CharField( + max_length=500, + required=False, + allow_blank=True, + help_text="Custom message to include in notification" + ) + + +class GetReceiptNotificationSerializer(BaseModelSerializer): + """Serializer for receipt notifications""" + + receipt_id = serializers.UUIDField(source='receipt.id') + receipt_number = serializers.CharField(source='receipt.internal_receipt_number') + + class Meta: + model = ReceiptNotification + fields = [ + 'id', 'receipt_id', 'receipt_number', 'notification_type', + 'sent_at', 'is_delivered' + ] diff --git a/open_schools_platform/receipt_management/receipts/services.py b/open_schools_platform/receipt_management/receipts/services.py new file mode 100644 index 00000000..fae9de09 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/services.py @@ -0,0 +1,696 @@ +import io +import logging +import re +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Dict, List, Tuple + +import pdfplumber +from dateutil.relativedelta import relativedelta +from django.core.files.base import ContentFile +from django.http import HttpResponse, Http404 +from pypdf import PdfWriter, PdfReader + +from open_schools_platform.receipt_management.receipts.selectors import get_receipts_for_family +from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptService +from open_schools_platform.student_management.students.models import StudentProfile + +logger = logging.getLogger(__name__) + +PATTERNS = { + 'internal_receipt_number': r'(?:Лицевой счет)[\s:]*(\d+)', + 'payer_full_name': r'(?:Плательщик)[\s:]*([А-Яа-яЁё\s]+?)(?:\s+Группа)', + 'recipient_full_name': r'(?:За кого)[\s:]*([А-Яа-яЁё\s]+)(?=\s*Счет от)', + 'institution_name': r'(Департамент[^)]*[)])', + 'service_category': r'(?:Группа)[\s:]*([А-ЯА-я\s\d]+?)(?=\s*Наименование платежа)', + 'payment_due_date': r'(?:Оплатить до)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})', + 'payment_purpose': r'(?:Наименование платежа)[\s:]*([^З]+?)(?=\s*За кого)', + 'receipt_date': r'(?:Счет от)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})', +} + +MONTHS_RU = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'] + + +def clean_row(row): + cleaned = [] + for cell in row: + if cell is None: + cleaned.append(cell) + else: + cell_str = str(cell).replace('\n', '').strip() + if re.match(r'^\d+[ ]*\d*,\d{2}$', cell_str): + cell_str = cell_str.replace(' ', '').replace(',', '.') + cleaned.append(cell_str) + return cleaned + + +def create_formatted_headers(text: str) -> List[str]: + receipt_date_match = re.search(PATTERNS['receipt_date'], text) + if not receipt_date_match: + return [] + + receipt_date = datetime.strptime(receipt_date_match.group(1), '%d.%m.%Y').date() + first_day_current = receipt_date.replace(day=1) + first_day_next = (receipt_date + relativedelta(months=1)).replace(day=1) + + month_name = MONTHS_RU[receipt_date.month - 1] + year = receipt_date.year + + return [ + 'Учреждение', + 'Вид услуги', + f'Задолженность на {first_day_current.strftime("%d.%m.%Y")}', + f'Начислено за {month_name} {year}', + 'Перерасчет за предыдущие периоды', + f'Оплачено {month_name} {year}', + f'Задолженность на {first_day_next.strftime("%d.%m.%Y")}', + 'Предоплата', + 'Итого к оплате в рублях' + ] + + +def extract_logical_receipts_from_pdf(pdf_file) -> List[Dict]: + """ + Extract logical receipts from PDF, handling both single and multi-receipt pages. + Returns a list of dictionaries, each representing a logical receipt. + """ + table_settings = { + "vertical_strategy": "lines", + "horizontal_strategy": "lines", + "intersection_x_tolerance": 4, + } + + pdf_file.seek(0) + logical_receipts = [] + + with pdfplumber.open(pdf_file) as pdf: + for page_num, page in enumerate(pdf.pages): + + tables = page.extract_tables(table_settings=table_settings) + logger.debug(f"Page {page_num + 1}: Found {len(tables)} tables") + + needs_horizontal_split = len(tables) >= 4 + if needs_horizontal_split: + page_width = page.width + page_height = page.height + mid_y = page_height / 2 + + regions_definitions = [ + {"name": "Upper Half", "bbox": (0, 0, page_width, mid_y)}, + {"name": "Lower Half", "bbox": (0, mid_y, page_width, page_height)} + ] + + for region_def in regions_definitions: + cropped_page = page.crop(region_def["bbox"]) + region_text = cropped_page.extract_text() or "" + region_raw_tables = cropped_page.extract_tables(table_settings) + + processed_region_table = [] + if region_raw_tables and region_raw_tables[0]: + first_raw_table = region_raw_tables[0] + temp_clean_table = [] + for row in first_raw_table: + if (sum(1 for cell in row if cell is not None) >= 3 and + not any('Учреждение' in str(cell or '') for cell in row)): + temp_clean_table.append(clean_row(row)) + + if temp_clean_table: + table_headers = create_formatted_headers(region_text) + if table_headers: + processed_region_table.append(table_headers) + processed_region_table.extend(temp_clean_table) + + logical_receipts.append({ + 'text': region_text, + 'processed_table': processed_region_table, + 'source_page_number': page_num + 1, + 'source_region_name': region_def["name"] + }) + + else: + page_text = page.extract_text() or "" + clean_table = [] + + if tables: + first_table = tables[0] + temp_clean_table = [] + for row in first_table: + if (sum(1 for cell in row if cell is not None) >= 3 and + not any('Учреждение' in str(cell or '') for cell in row)): + temp_clean_table.append(clean_row(row)) + + if temp_clean_table: + headers = create_formatted_headers(page_text) + if headers: + clean_table.append(headers) + clean_table.extend(temp_clean_table) + + logical_receipts.append({ + 'text': page_text, + 'processed_table': clean_table, + 'source_page_number': page_num + 1, + 'source_region_name': "Full Page" + }) + + logger.info(f"Total logical receipts found: {len(logical_receipts)}") + return logical_receipts + +def parse_amount(amount_str: str) -> Decimal: + if not amount_str or amount_str == '0.00': + return Decimal('0.00') + + try: + return Decimal(str(amount_str)) + except (ValueError, InvalidOperation): + return Decimal('0.00') + + +class PDFReceiptParser: + + def __init__(self): + self.patterns = { + 'internal_receipt_number': r'(?:Лицевой счет)[\s:]*(\d+)', + 'payer_full_name': r'(?:Плательщик)[\s:]*([А-Яа-яЁё\s]+?)(?:\s+Группа)', + 'recipient_full_name': r'(?:За кого)[\s:]*([А-Яа-яЁё\s]+)(?=\s*Счет от)', + 'institution_name': r'(Департамент[^)]*[)])', + 'service_category': r'(?:Группа)[\s:]*([А-ЯА-я\s\d]+?)(?=\s*Наименование платежа)', + 'payment_due_date': r'(?:Оплатить до)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})', + 'payment_purpose': r'(?:Наименование платежа)[\s:]*([^З]+?)(?=\s*За кого)', + 'receipt_date': r'(?:Счет от)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})', + } + + def extract_financial_data_from_table(self, tables: List) -> Dict: + """Extract and sum financial data from table rows""" + financial_data = { + 'debt_at_month_start': Decimal('0.00'), + 'charged_this_month': Decimal('0.00'), + 'recalculation_amount': Decimal('0.00'), + 'paid_amount': Decimal('0.00'), + 'debt_at_next_month_start': Decimal('0.00'), + 'prepayment': Decimal('0.00'), + 'service_amount': Decimal('0.00'), + 'services': [] + } + if not tables or len(tables) < 2: + return financial_data + + for row in tables[1:]: + if not row or len(row) < 9: + continue + + service_info = { + 'institution': row[0], + 'service_name': row[1], + 'debt_at_month_start': parse_amount(row[2]), + 'charged_this_month': parse_amount(row[3]), + 'recalculation_amount': parse_amount(row[4]), + 'paid_amount': parse_amount(row[5]), + 'debt_at_next_month_start': parse_amount(row[6]), + 'prepayment': parse_amount(row[7]), + 'service_amount': parse_amount(row[8]) + } + + + financial_data['services'].append(service_info) + + financial_data['debt_at_month_start'] += service_info['debt_at_month_start'] + financial_data['charged_this_month'] += service_info['charged_this_month'] + financial_data['recalculation_amount'] += service_info['recalculation_amount'] + financial_data['paid_amount'] += service_info['paid_amount'] + financial_data['debt_at_next_month_start'] += service_info['debt_at_next_month_start'] + financial_data['prepayment'] += service_info['prepayment'] + financial_data['service_amount'] += service_info['service_amount'] + + return financial_data + + def extract_receipt_data(self, text, tables): + extracted_data = {} + + for field, pattern in self.patterns.items(): + match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) + if match: + try: + value = match.group(1).strip() + + if field in ['receipt_date', 'payment_due_date']: + value = datetime.strptime(value, '%d.%m.%Y') + value = value.strftime('%Y-%m-%d') + + extracted_data[field] = value + + except IndexError: + value = match.group(0).strip() + extracted_data[field] = value + + financial_data = self.extract_financial_data_from_table(tables) + extracted_data.update(financial_data) + + extracted_data['service_amount'] = financial_data['service_amount'] + extracted_data['total_amount'] = financial_data['service_amount'] + + service_names = [s['service_name'] for s in financial_data['services'] if s['service_name']] + extracted_data['service_name'] = ', '.join(service_names) if service_names else "Образовательные услуги" + + return extracted_data + + def parse_pdf_receipt(self, pdf_file, student_profile: StudentProfile = None) -> List[Dict]: + """ + Main method to parse PDF receipt and return structured data for all logical receipts. + Returns a list of receipt data dictionaries. + """ + try: + logical_receipts = extract_logical_receipts_from_pdf(pdf_file) + + logger.info(f"Found {len(logical_receipts)} logical receipts") + + parsed_receipts = [] + + for logical_receipt in logical_receipts: + receipt_data = self.extract_receipt_data( + logical_receipt['text'], + logical_receipt['processed_table'] + ) + receipt_data['source_page_number'] = logical_receipt['source_page_number'] + receipt_data['source_region_name'] = logical_receipt['source_region_name'] + + logger.info( + f"Extracted receipt data for {logical_receipt['source_region_name']} on page {logical_receipt['source_page_number']}: {receipt_data}") + + if student_profile and isinstance(student_profile, StudentProfile): + receipt_data.setdefault('student_profile', student_profile) + receipt_data.setdefault('recipient_full_name', student_profile.name) + + receipt_data.setdefault('internal_receipt_number', f"REC-{datetime.now().strftime('%Y%m%d%H%M%S%f')}") + receipt_data.setdefault('institution_name', "N/A") + receipt_data.setdefault('service_name', "N/A") + receipt_data.setdefault('service_amount', Decimal('0.00')) + receipt_data.setdefault('total_amount', Decimal('0.00')) + receipt_data.setdefault('receipt_date', date.today()) + receipt_data.setdefault('payment_due_date', date.today()) + receipt_data.setdefault('payment_purpose', "N/A") + receipt_data.setdefault('service_category', "N/A") + + parsed_receipts.append(receipt_data) + + return parsed_receipts + + except Exception as e: + logger.error(f"Error parsing PDF receipt: {str(e)}") + raise ValueError(f"Failed to parse PDF receipt: {str(e)}") + + +def process_pdf_receipt(pdf_file, student_profile: StudentProfile) -> Receipt: + """ + Process uploaded PDF file and create receipt, processes only first recipe. + Used for legacy compatibility - expects StudentProfile instance + """ + result = process_pdf_receipts_bulk(pdf_file, student_profile.id if student_profile else None) + return result["created_receipts"][0] if result["created_receipts"] else None + + +def process_pdf_receipts_bulk(pdf_file, student_profile_id=None) -> Dict: + """ + Process uploaded PDF file and create receipts for all logical receipts found + Args: + pdf_file: The PDF file to process + student_profile_id: StudentProfile ID (UUID string) or None + Returns: + Dict with: + - created_receipts: List[Receipt] - Successfully created receipts + - failed_details: List[Dict] - Details about failed receipt creations + - created_count: int - Number of successfully created receipts + - failed_count: int - Number of failed receipt creations + """ + parser = PDFReceiptParser() + + actual_student_profile = None + if student_profile_id is not None: + try: + actual_student_profile = StudentProfile.objects.get(id=student_profile_id) + logger.info(f"Found StudentProfile {actual_student_profile.id}") + except StudentProfile.DoesNotExist: + logger.warning(f"No StudentProfile found for ID {student_profile_id}") + actual_student_profile = None + except Exception as e: + logger.error(f"Error getting StudentProfile for ID {student_profile_id}: {str(e)}") + actual_student_profile = None + + receipt_data_list = parser.parse_pdf_receipt(pdf_file, actual_student_profile) + + created_receipts = [] + failed_details = [] + + for i, receipt_data in enumerate(receipt_data_list): + source_page_number = receipt_data.get('source_page_number', 1) + source_region_name = receipt_data.get('source_region_name', 'Unknown') + + try: + pdf_file.seek(0) + pdf_reader = PdfReader(pdf_file) + pdf_writer = PdfWriter() + + page_index = source_page_number - 1 + if page_index < len(pdf_reader.pages): + pdf_writer.add_page(pdf_reader.pages[page_index]) + + page_pdf_buffer = io.BytesIO() + pdf_writer.write(page_pdf_buffer) + page_pdf_buffer.seek(0) + + pdf_content = ContentFile( + page_pdf_buffer.read(), + name=f"receipt_p{source_page_number}_{source_region_name.replace(' ', '_').lower()}.pdf" + ) + receipt_data['pdf_file'] = pdf_content + + else: + logger.warning(f"Page {source_page_number} not found in PDF, using entire PDF as fallback") + pdf_file.seek(0) + pdf_content = ContentFile(pdf_file.read()) + receipt_data['pdf_file'] = pdf_content + + except Exception as pdf_error: + logger.warning(f"Error extracting page {source_page_number}: {str(pdf_error)}, using entire PDF as fallback") + pdf_file.seek(0) + pdf_content = ContentFile(pdf_file.read()) + receipt_data['pdf_file'] = pdf_content + + receipt_data.pop('source_page_number', None) + receipt_data.pop('source_region_name', None) + + if actual_student_profile: + receipt_data['student_profile'] = actual_student_profile + elif 'student_profile' in receipt_data: + receipt_data.pop('student_profile', None) + + try: + receipt = create_receipt(**receipt_data) + created_receipts.append(receipt) + logger.info( + f"Processed PDF receipt {receipt.id} for student {receipt_data.get('recipient_full_name', 'Unknown')} from page {source_page_number} ({source_region_name})") + + except Exception as e: + error_detail = { + "index": i, + "page": source_page_number, + "region": source_region_name, + "recipient_name": receipt_data.get('recipient_full_name', 'Unknown'), + "error": str(e) + } + failed_details.append(error_detail) + logger.error(f"Failed to create receipt from page {source_page_number} ({source_region_name}): {str(e)}") + continue + + return { + "created_receipts": created_receipts, + "failed_details": failed_details, + "created_count": len(created_receipts), + "failed_count": len(failed_details) + } + + +def create_receipt(student_profile: StudentProfile, **receipt_data) -> Receipt: + """ + Create a new receipt and associated services + """ + services_data = receipt_data.pop('services', []) + + if 'total_amount' not in receipt_data and 'service_amount' in receipt_data: + receipt_data['total_amount'] = receipt_data['service_amount'] + + receipt = Receipt.objects.create_receipt( + student_profile=student_profile, + **receipt_data + ) + + for service_data in services_data: + ReceiptService.objects.create( + receipt=receipt, + **service_data + ) + + logger.info(f"Created receipt {receipt.id} with {len(services_data)} services for student {student_profile.name}") + return receipt + + +class PDFDownloadService: + """ + Service for handling PDF download operations for receipts + """ + + def download_single_receipt_pdf(self, receipt: Receipt) -> HttpResponse: + """ + Download PDF for a single receipt + + Args: + receipt: Receipt instance + + Returns: + HttpResponse with PDF content + + Raises: + Http404: If PDF file not found + """ + if not receipt.pdf_file: + raise Http404("PDF file not found for this receipt.") + + try: + response = HttpResponse(receipt.pdf_file.read(), content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="receipt_{receipt.internal_receipt_number}.pdf"' + logger.info(f"Downloaded PDF for receipt {receipt.id}") + return response + except FileNotFoundError: + logger.error(f"PDF file not found on storage for receipt {receipt.id}") + raise Http404("PDF file not found on storage.") + + def download_consolidated_family_pdf(self, family, filters=None) -> HttpResponse: + """ + Download consolidated PDF for all receipts in a family + Args: + family: Family instance + filters: Optional filters for receipts + Returns: + HttpResponse with consolidated PDF content + Raises: + Http404: If no receipts or valid PDFs found + """ + receipts = get_receipts_for_family(family=family, filters=filters or {}) + if not receipts.exists(): + raise Http404("No receipts found for this family.") + merger = PdfWriter() + processed_count = 0 + for receipt in receipts: + if receipt.pdf_file: + try: + pdf_file_buffer = io.BytesIO(receipt.pdf_file.read()) + reader = PdfReader(pdf_file_buffer) + for page in reader.pages: + merger.add_page(page) + receipt.pdf_file.seek(0) + processed_count += 1 + logger.debug(f"Added PDF for receipt {receipt.id} to consolidated file") + except FileNotFoundError: + logger.warning(f"PDF for receipt {receipt.id} not found on storage. Skipping.") + continue + except Exception as e: + logger.warning(f"Error processing PDF for receipt {receipt.id}: {e}. Skipping.") + continue + if not merger.pages: + raise Http404("No valid PDF files found to merge for this family's receipts.") + output_buffer = io.BytesIO() + merger.write(output_buffer) + output_buffer.seek(0) + response = HttpResponse(output_buffer.read(), content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="family_{family.id}_receipts.pdf"' + logger.info(f"Generated consolidated PDF for family {family.id} with {processed_count} receipts") + return response + + +class ReceiptUpdateService: + """ + Service for updating receipt records + """ + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def update_receipt(self, receipt: Receipt, **receipt_data) -> Receipt: + """ + Update an existing receipt + + Args: + receipt: Receipt instance to update + **receipt_data: Fields to update + + Returns: + Updated Receipt instance + """ + from open_schools_platform.common.services import model_update + + fields = [ + 'internal_receipt_number', 'payer_full_name', 'recipient_full_name', + 'institution_name', 'service_name', 'service_category', + 'debt_at_month_start', 'recalculation_amount', 'paid_amount', + 'debt_at_next_month_start', 'prepayment', 'service_amount', + 'total_amount', 'receipt_date', 'payment_due_date', 'payment_purpose', + 'qr_code_data', 'pdf_file' + ] + + updated_receipt = model_update( + instance=receipt, + fields=fields, + data=receipt_data + ) + self.logger.info(f"Updated receipt {receipt.id}") + return updated_receipt + + +class ReceiptNotificationService: + """ + Service for handling receipt notifications + """ + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def send_receipt_notification(self, receipt: Receipt, notification_type: str = 'initial', + custom_message: str = '') -> bool: + """ + Send notification to parents about receipt + + Args: + receipt: Receipt instance + notification_type: Type of notification ('initial', 'reminder', 'overdue') + custom_message: Optional custom message to append + + Returns: + True if notification sent successfully, False otherwise + """ + try: + from open_schools_platform.receipt_management.receipts.models import ReceiptNotification + from open_schools_platform.user_management.users.services import notify_user + from django.utils import timezone + + families = receipt.student_profile.families.all() + parent_profiles = [] + + for family in families: + parent_profiles.extend(family.parent_profiles.all()) + + if not parent_profiles: + self.logger.warning(f"No parent profiles found for receipt {receipt.id}") + return False + + notification = ReceiptNotification.objects.create_notification( + receipt=receipt, + notification_type=notification_type + ) + + title = self._get_notification_title(notification_type) + body = self._get_notification_body(receipt, notification_type, custom_message) + + successful_sends = 0 + for parent_profile in parent_profiles: + if hasattr(parent_profile.user, 'firebase_token'): + success = notify_user( + user=parent_profile.user, + title=title, + body=body, + data={'receipt_id': str(receipt.id)} + ) + if success: + successful_sends += 1 + + if successful_sends > 0: + notification.is_sent = True + notification.sent_at = timezone.now() + notification.save() + + self.logger.info(f"Sent receipt notification {notification.id} to {successful_sends} parents") + return successful_sends > 0 + + except Exception as e: + self.logger.error(f"Error sending receipt notification: {str(e)}") + return False + + def bulk_send_receipt_notifications(self, receipt_ids: List[str], notification_type: str = 'initial', + custom_message: str = '') -> Dict[str, int]: + """ + Send notifications for multiple receipts + + Args: + receipt_ids: List of receipt IDs + notification_type: Type of notification + custom_message: Optional custom message + + Returns: + Dictionary with success and failed counts + """ + results = {'success': 0, 'failed': 0} + + for receipt_id in receipt_ids: + try: + receipt = Receipt.objects.get(id=receipt_id) + success = self.send_receipt_notification(receipt, notification_type, custom_message) + if success: + results['success'] += 1 + else: + results['failed'] += 1 + + except Receipt.DoesNotExist: + self.logger.error(f"Receipt {receipt_id} not found") + results['failed'] += 1 + except Exception as e: + self.logger.error(f"Error sending notification for receipt {receipt_id}: {str(e)}") + results['failed'] += 1 + + self.logger.info(f"Bulk notification results: {results['success']} successful, {results['failed']} failed") + return results + + def _get_notification_title(self, notification_type: str) -> str: + """ + Get notification title based on type + """ + titles = { + 'initial': 'Новая квитанция', + 'reminder': 'Напоминание об оплате', + 'overdue': 'Просрочен платёж' + } + return titles.get(notification_type, 'Уведомление о платеже') + + def _get_notification_body(self, receipt: Receipt, notification_type: str, custom_message: str = '') -> str: + """ + Get notification body text + """ + base_messages = { + 'initial': f'Получена новая квитанция на сумму {receipt.total_amount} руб. для {receipt.recipient_full_name}', + 'reminder': f'Напоминаем об оплате квитанции на сумму {receipt.total_amount} руб. до {receipt.payment_due_date}', + 'overdue': f'Просрочен платёж по квитанции на сумму {receipt.total_amount} руб. для {receipt.recipient_full_name}' + } + + body = base_messages.get(notification_type, f'Квитанция на сумму {receipt.total_amount} руб.') + + if custom_message: + body += f"\n\n{custom_message}" + + return body + + +def update_receipt(receipt: Receipt, **receipt_data) -> Receipt: + service = ReceiptUpdateService() + return service.update_receipt(receipt, **receipt_data) + + +def send_receipt_notification(receipt: Receipt, notification_type: str = 'initial', + custom_message: str = '') -> bool: + """Sends receipt notifications""" + service = ReceiptNotificationService() + return service.send_receipt_notification(receipt, notification_type, custom_message) + + +def bulk_send_receipt_notifications(receipt_ids: List[str], notification_type: str = 'initial', + custom_message: str = '') -> Dict[str, int]: + """Bulk sending receipt notifications""" + service = ReceiptNotificationService() + return service.bulk_send_receipt_notifications(receipt_ids, notification_type, custom_message) diff --git a/open_schools_platform/receipt_management/receipts/urls.py b/open_schools_platform/receipt_management/receipts/urls.py new file mode 100644 index 00000000..65ea8004 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/urls.py @@ -0,0 +1,25 @@ +from django.urls import path + +from open_schools_platform.common.views import MultipleViewManager +from open_schools_platform.receipt_management.receipts.views import ( + ReceiptCreateApi, ReceiptDetailApi, ReceiptListApi, FamilyReceiptListApi, + ReceiptPDFDownloadApi, ReceiptPDFUploadApi, BulkReceiptPDFUploadApi, + ReceiptNotificationSendApi, FamilyReceiptStatisticsApi +) + +urlpatterns = [ + path('', MultipleViewManager({'get': ReceiptListApi, 'post': ReceiptCreateApi}).as_view(), + name='receipts-list-create'), + path('/', ReceiptDetailApi.as_view(), name='receipt-detail'), + + path('/family/', FamilyReceiptListApi.as_view(), name='family-receipts'), + path('/family//statistics', FamilyReceiptStatisticsApi.as_view(), name='family-receipt-statistics'), + path('/family//download', ReceiptPDFDownloadApi.as_view(), name='family-receipts-pdf-download'), + + path('//download', ReceiptPDFDownloadApi.as_view(), name='receipt-pdf-download'), + + path('/upload-pdf', ReceiptPDFUploadApi.as_view(), name='receipt-pdf-upload'), + path('/upload-bulk-pdf', BulkReceiptPDFUploadApi.as_view(), name='receipt-bulk-pdf-upload'), + + path('/send-notifications', ReceiptNotificationSendApi.as_view(), name='receipt-send-notifications'), +] diff --git a/open_schools_platform/receipt_management/receipts/views.py b/open_schools_platform/receipt_management/receipts/views.py new file mode 100644 index 00000000..e0587e78 --- /dev/null +++ b/open_schools_platform/receipt_management/receipts/views.py @@ -0,0 +1,339 @@ +from django.http import Http404 +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status, serializers +from rest_framework.generics import ListAPIView +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView +import logging # Added + +from open_schools_platform.api.mixins import ApiAuthMixin +from open_schools_platform.api.pagination import get_paginated_response +from open_schools_platform.api.swagger_tags import SwaggerTags +from open_schools_platform.common.paginators import DefaultListPagination +from open_schools_platform.common.views import convert_dict_to_serializer +from open_schools_platform.parent_management.families.selectors import get_family +from open_schools_platform.receipt_management.receipts.selectors import ( + get_receipt, get_receipts_with_filters, get_receipts_for_family, + get_receipt_statistics_for_family, get_notifications_for_receipt, + get_receipt_notifications +) +from open_schools_platform.receipt_management.receipts.serializers import ( + CreateReceiptSerializer, UpdateReceiptSerializer, GetReceiptSerializer, + GetReceiptListSerializer, GetReceiptDetailedSerializer, + UploadReceiptPDFSerializer, SendReceiptNotificationSerializer, + GetReceiptNotificationSerializer +) +from open_schools_platform.receipt_management.receipts.services import create_receipt, process_pdf_receipt, \ + PDFDownloadService, update_receipt, bulk_send_receipt_notifications, process_pdf_receipts_bulk +from open_schools_platform.student_management.students.selectors import get_student_profile + +logger = logging.getLogger(__name__) # Added + + +class ReceiptCreateApi(ApiAuthMixin, APIView): + """ + Create a new receipt (Dispatcher Web App) + """ + + @swagger_auto_schema( + operation_description="Create a new receipt for a student", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + request_body=CreateReceiptSerializer(), + responses={201: GetReceiptDetailedSerializer()} + ) + def post(self, request): + serializer = CreateReceiptSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + receipt = create_receipt(**serializer.validated_data) + + response_serializer = GetReceiptDetailedSerializer(receipt) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + +class BulkReceiptPDFUploadApi(ApiAuthMixin, APIView): + """ + Upload and parse a PDF file containing multiple receipts (Dispatcher Web App) + """ + parser_classes = [MultiPartParser] + + @swagger_auto_schema( + operation_description="Upload a single PDF file containing multiple receipts. The system will attempt to parse each receipt, identify the student, and create corresponding receipt records.", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + request_body=UploadReceiptPDFSerializer, + responses={ + 200: convert_dict_to_serializer({ + "created_count": serializers.IntegerField(), + "failed_count": serializers.IntegerField(), + "created_receipts": GetReceiptDetailedSerializer(many=True), + "failed_details": serializers.ListField(child=serializers.DictField()) + }), + 400: "Invalid PDF file or processing error" + } + ) + def post(self, request): + serializer = UploadReceiptPDFSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + pdf_file = request.FILES.get('pdf_file') + student_profile_id = serializer.validated_data['student_profile_id'] + + if not pdf_file: + return Response({"detail": "No PDF file provided."}, status=status.HTTP_400_BAD_REQUEST) + + if not pdf_file.name.endswith('.pdf'): + return Response({"detail": "File must be a PDF."}, status=status.HTTP_400_BAD_REQUEST) + + try: + result = process_pdf_receipts_bulk(pdf_file, student_profile_id) + + serialized_receipts = GetReceiptDetailedSerializer(result["created_receipts"], many=True).data + + response_data = { + "created_count": result["created_count"], + "failed_count": result["failed_count"], + "created_receipts": serialized_receipts, + "failed_details": result["failed_details"] + } + + return Response(response_data, status=status.HTTP_200_OK) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + logger.error(f"Error processing bulk PDF upload: {str(e)}") + return Response({"detail": "An error occurred while processing the PDF."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class ReceiptDetailApi(ApiAuthMixin, APIView): + """ + Get, update, or delete a specific receipt + """ + + @swagger_auto_schema( + operation_description="Get receipt details", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={200: GetReceiptDetailedSerializer()} + ) + def get(self, request, receipt_id): + receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True) + + # todo: Нужная сюда какая-то проверка на семью-родителя-ребенка-тд? + + serializer = GetReceiptDetailedSerializer(receipt) + return Response(serializer.data) + + @swagger_auto_schema( + operation_description="Update receipt details", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + request_body=UpdateReceiptSerializer(), + responses={200: GetReceiptDetailedSerializer()} + ) + def patch(self, request, receipt_id): + receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True) + + serializer = UpdateReceiptSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + updated_receipt = update_receipt(receipt, **serializer.validated_data) + + response_serializer = GetReceiptDetailedSerializer(updated_receipt) + return Response(response_serializer.data) + + @swagger_auto_schema( + operation_description="Delete a receipt", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={204: "Receipt deleted successfully"} + ) + def delete(self, request, receipt_id): + receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True) + receipt.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ReceiptListApi(ApiAuthMixin, ListAPIView): + """ + List receipts with filtering (for both parents and dispatchers) + """ + + pagination_class = DefaultListPagination + + @swagger_auto_schema( + operation_description="Get list of receipts", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={200: GetReceiptListSerializer(many=True)} + ) + def get(self, request): + pagination_params = ['page', 'page_size', 'limit', 'offset'] + filters = {key: value for key, value in request.GET.dict().items() + if key not in pagination_params} + + # todo: Тоже здесь проверку прав накинуть + + receipts = get_receipts_with_filters(user=request.user, filters=filters) + + response = get_paginated_response( + pagination_class=self.pagination_class, + serializer_class=GetReceiptListSerializer, + queryset=receipts, + request=request, + view=self + ) + return response + + +class FamilyReceiptListApi(ApiAuthMixin, APIView): + """ + Get receipts for a specific family (Parent Mobile App) + """ + + @swagger_auto_schema( + operation_description="Get receipts for a family", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={200: convert_dict_to_serializer({ + "results": GetReceiptListSerializer(many=True) + })} + ) + def get(self, request, family_id): + family = get_family(filters={'id': family_id}, empty_exception=True) + + receipts = get_receipts_for_family(family, request.GET.dict()) + serializer = GetReceiptListSerializer(receipts, many=True) + return Response({"results": serializer.data}) + + +class ReceiptPDFDownloadApi(ApiAuthMixin, APIView): + """ + Download receipt PDF (Parent Mobile App) + Can download a single receipt or a consolidated PDF for all receipts in a family. + """ + + @swagger_auto_schema( + operation_description="Download receipt PDF file. \n\n" + "If 'receipt_id' is provided, downloads a single receipt PDF.\n" + "If 'family_id' is provided, downloads a consolidated PDF of all receipts for that family.", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={200: "PDF file"}) + def get(self, request, receipt_id=None, family_id=None): + pdf_service = PDFDownloadService() + + if receipt_id: + receipt = get_receipt(filters={'id': receipt_id}, user=request.user, empty_exception=True) + return pdf_service.download_single_receipt_pdf(receipt) + + elif family_id: + family = get_family(filters={'id': family_id}, user=request.user, empty_exception=True) + return pdf_service.download_consolidated_family_pdf(family, request.GET.dict()) + + else: + return Response({"detail": "Either receipt_id or family_id must be provided."}, + status=status.HTTP_400_BAD_REQUEST) + + +class ReceiptPDFUploadApi(ApiAuthMixin, APIView): + """ + Upload and parse PDF receipt (Dispatcher Web App) + """ + parser_classes = [MultiPartParser] + + @swagger_auto_schema( + operation_description="Upload PDF receipt file and create receipt", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + request_body=UploadReceiptPDFSerializer(), + responses={201: GetReceiptDetailedSerializer()} + ) + def post(self, request): + serializer = UploadReceiptPDFSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + pdf_file = serializer.validated_data['pdf_file'] + student_profile_id = serializer.validated_data['student_profile_id'] + + student_profile = get_student_profile( + filters={'id': student_profile_id}, + empty_exception=True + ) + + receipt = process_pdf_receipt(pdf_file, student_profile) + + response_serializer = GetReceiptDetailedSerializer(receipt) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + +class ReceiptNotificationSendApi(ApiAuthMixin, APIView): + """ + Send receipt notifications (Dispatcher Web App) + """ + + @swagger_auto_schema( + operation_description="Send notifications for receipts", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + request_body=SendReceiptNotificationSerializer(), + responses={200: convert_dict_to_serializer({ + "sent_count": serializers.IntegerField(), + "failed_count": serializers.IntegerField() + })} + ) + def post(self, request): + serializer = SendReceiptNotificationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + receipt_ids = serializer.validated_data['receipt_ids'] + notification_type = serializer.validated_data['notification_type'] + custom_message = serializer.validated_data.get('custom_message', '') + + results = bulk_send_receipt_notifications( + receipt_ids, notification_type, custom_message + ) + + return Response({"results": results}) + + +class ReceiptNotificationListApi(ApiAuthMixin, APIView): + """ + List receipt notifications (Dispatcher Web App) + """ + + @swagger_auto_schema( + operation_description="Get list of receipt notifications", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={200: convert_dict_to_serializer({ + "results": GetReceiptNotificationSerializer(many=True) + })} + ) + def get(self, request, receipt_id=None): + if receipt_id: + receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True) + notifications = get_notifications_for_receipt(receipt) + else: + filters = request.GET.dict() + notifications = get_receipt_notifications(filters=filters) + + serializer = GetReceiptNotificationSerializer(notifications, many=True) + return Response({"results": serializer.data}) + + +class FamilyReceiptStatisticsApi(ApiAuthMixin, APIView): + """ + Get receipt statistics for a family (Parent Mobile App) + """ + + @swagger_auto_schema( + operation_description="Get receipt statistics for a family", + tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS], + responses={200: convert_dict_to_serializer({ + "total_receipts": serializers.IntegerField(), + "overdue_receipts": serializers.IntegerField(), + "viewed_receipts": serializers.IntegerField(), + "total_amount": serializers.DecimalField(max_digits=10, decimal_places=2) + })} + ) + def get(self, request, family_id): + family = get_family(filters={'id': family_id}, empty_exception=True) + + if hasattr(request.user, 'parent_profile'): + if family not in request.user.parent_profile.families.all(): + raise Http404("Family not found") + + statistics = get_receipt_statistics_for_family(family) + return Response({"statistics": statistics}) diff --git a/requirements/base.txt b/requirements/base.txt index 69dfb4cc..ec06ec1e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -41,4 +41,6 @@ sentry-sdk==1.8.0 icalendar==5.0.4 django-lifecycle==1.0.0 gunicorn==20.1.0 -drf-yasg==1.20.0 \ No newline at end of file +drf-yasg==1.20.0 +pdfplumber==0.7.6 +pypdf>=3.0.0 \ No newline at end of file