fix fish variz name, dalilrad, sms, bale
This commit is contained in:
1 parent
90931a5a64
commit
35a1ec1794
30 files changed
+674
-39
No files matched your search
@@ -6,6 +6,8 @@ from django.utils import timezone
|
||||
from apps.accounts.models import User
|
||||
from . import validators
|
||||
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
|
||||
from .identity import customer_name, depositor_name
|
||||
from .deposit_identifier import generate_id_algorithm
|
||||
|
||||
|
||||
class ReceiptAlertConfigurationForm(forms.ModelForm):
|
||||
@@ -94,6 +96,7 @@ class NoticeForm(forms.Form):
|
||||
|
||||
|
||||
class ReceiptForm(forms.Form):
|
||||
depositor_name = forms.CharField(label='نام واریزکننده', max_length=150)
|
||||
receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked')
|
||||
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
|
||||
paid_amount = forms.CharField(label='مبلغ پرداختشده (ریال)')
|
||||
@@ -111,6 +114,7 @@ class ReceiptForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, customer, notice_ids=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial.setdefault('depositor_name', customer_name(customer))
|
||||
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
|
||||
if notice_ids is not None:
|
||||
notices = notices.filter(pk__in=notice_ids)
|
||||
@@ -118,6 +122,9 @@ class ReceiptForm(forms.Form):
|
||||
self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}'
|
||||
accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices)
|
||||
self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct()
|
||||
self.account_warnings = {str(a.pk): {'required': a.requires_deposit_id, 'bank': a.bank.name}
|
||||
for a in self.fields['destination_account'].queryset.select_related('bank')}
|
||||
self.customer_deposit_id = generate_id_algorithm(customer.local_mobile) if customer.local_mobile else ''
|
||||
current = jdatetime.date.fromgregorian(date=timezone.localdate())
|
||||
self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)]
|
||||
self.initial.setdefault('submission_key', uuid.uuid4())
|
||||
@@ -127,6 +134,9 @@ class ReceiptForm(forms.Form):
|
||||
def clean_paid_amount(self):
|
||||
return validators.amount(self.cleaned_data['paid_amount'])
|
||||
|
||||
def clean_depositor_name(self):
|
||||
return depositor_name(self.cleaned_data['depositor_name'])
|
||||
|
||||
def clean_receipt_image(self):
|
||||
upload = self.cleaned_data['receipt_image']
|
||||
self.image_metadata = validators.image(upload)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Customer identity and the payer recorded on each receipt are independent."""
|
||||
from django.core.exceptions import ValidationError
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
|
||||
|
||||
def customer_name(customer):
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
|
||||
return normalize_persian_text(name or customer.get_full_name())
|
||||
|
||||
|
||||
def depositor_name(value):
|
||||
name = normalize_persian_text(value)
|
||||
if not name.replace('\u200c', '').replace('\u200b', '').strip():
|
||||
raise ValidationError('نام واریزکننده را وارد کنید.')
|
||||
if len(name) > 150:
|
||||
raise ValidationError('نام واریزکننده باید حداکثر ۱۵۰ کاراکتر باشد.')
|
||||
return name
|
||||
|
||||
|
||||
def receipt_identity(customer, payer):
|
||||
name = customer_name(customer)
|
||||
payer = normalize_persian_text(payer)
|
||||
if not name:
|
||||
return payer or 'نام ثبت نشده'
|
||||
if not payer or name == payer:
|
||||
return name
|
||||
return f'{name} (واریزکننده: {payer})'
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('payments', '0007_receipt_alerts')]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='paymentreceiptrevision', name='depositor_name',
|
||||
field=models.CharField('نام واریزکننده', max_length=150, default=''),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -134,6 +134,7 @@ def receipt_path(instance, filename):
|
||||
class PaymentReceiptRevision(models.Model):
|
||||
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
|
||||
version = models.PositiveIntegerField()
|
||||
depositor_name = models.CharField('نام واریزکننده', max_length=150)
|
||||
amount = models.BigIntegerField()
|
||||
paid_date = models.DateField()
|
||||
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
|
||||
|
||||
@@ -5,8 +5,8 @@ from django.utils import timezone
|
||||
from apps.notifications.services import enqueue
|
||||
from .models import PaymentNotification, PaymentNotificationAttempt
|
||||
|
||||
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
|
||||
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
|
||||
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}
|
||||
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason']
|
||||
|
||||
|
||||
def notice_sms_pending(notice):
|
||||
@@ -17,7 +17,7 @@ def context(customer, notice=None, receipt=None, amount=0):
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
|
||||
'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '',
|
||||
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '',
|
||||
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '', 'rejection_reason': '',
|
||||
'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')}
|
||||
|
||||
|
||||
@@ -31,9 +31,25 @@ def queue_approval(receipt, cycle):
|
||||
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)})
|
||||
|
||||
|
||||
def queue_rejection(receipt, cycle, reason):
|
||||
job = enqueue('SMS', 'PAYMENT_RECEIPT_REJECTED', None, f'payment-rejected:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
|
||||
details = context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)
|
||||
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit,
|
||||
'context': {**details, 'rejection_reason': reason}})
|
||||
|
||||
|
||||
def payment_sms_body(job, notification):
|
||||
"""Editable templates must always include the customer's rejection reason."""
|
||||
from apps.notifications.management.commands.process_notifications import _render_sms
|
||||
body = _render_sms(job.event_type, notification.context, notification.unit)
|
||||
reason = notification.context.get('rejection_reason', '')
|
||||
if reason and reason not in body:
|
||||
body += '\nدلیل کارشناس: ' + reason
|
||||
return body
|
||||
|
||||
|
||||
def send_payment_sms(job, provider=None, provider_factory=None):
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.notifications.management.commands.process_notifications import _render_sms
|
||||
notification = PaymentNotification.objects.get(job=job)
|
||||
# Persist the attempt before rendering/provider construction: failed preparation is history too.
|
||||
attempt = PaymentNotificationAttempt.objects.create(notification=notification)
|
||||
@@ -52,7 +68,7 @@ def send_payment_sms(job, provider=None, provider_factory=None):
|
||||
notification.context = {**notification.context, 'payment_link': url}
|
||||
notification.save(update_fields=['context', 'access_link', 'rendered_body'])
|
||||
if not notification.rendered_body:
|
||||
body = _render_sms(job.event_type, notification.context, notification.unit)
|
||||
body = payment_sms_body(job, notification)
|
||||
template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None
|
||||
template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
|
||||
notification.rendered_body = body
|
||||
|
||||
@@ -17,10 +17,10 @@ RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
|
||||
|
||||
|
||||
def receipt_alert_context(revision):
|
||||
from .identity import receipt_identity
|
||||
customer = revision.receipt.customer
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
|
||||
return {'customer_name': name or customer.get_full_name() or 'نام ثبت نشده',
|
||||
return {'customer_name': receipt_identity(customer, revision.depositor_name),
|
||||
'depositor_name': revision.depositor_name,
|
||||
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
|
||||
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
|
||||
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
|
||||
@@ -83,6 +83,8 @@ def send_receipt_alert(job, sms_provider_factory=None):
|
||||
if not alert.rendered_body:
|
||||
validate_alert_template(alert.message_template)
|
||||
alert.rendered_body = alert.message_template.format(**alert.context)
|
||||
if alert.context.get('depositor_name') and '{customer_name}' not in alert.message_template:
|
||||
alert.rendered_body += '\nمشتری / واریزکننده: ' + alert.context['customer_name']
|
||||
alert.save(update_fields=['rendered_body'])
|
||||
if job.channel == 'SMS':
|
||||
from apps.integrations.sms.providers import get_sms_provider
|
||||
|
||||
@@ -44,6 +44,7 @@ def notices_with_totals(queryset):
|
||||
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]))
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ def assign_cycle(revision, actor):
|
||||
|
||||
def receipt_metadata(data):
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
|
||||
from .identity import depositor_name
|
||||
return {'depositor_name': depositor_name(data.get('depositor_name')), 'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
|
||||
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
|
||||
'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
|
||||
'description': data.get('description', '')}
|
||||
@@ -157,6 +158,7 @@ def validate_destination(account, notice):
|
||||
|
||||
|
||||
def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
fields = receipt_metadata(data)
|
||||
# Same-customer lock serializes retries of a submission nonce before inserting it.
|
||||
User.objects.select_for_update().get(pk=customer.pk)
|
||||
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
|
||||
@@ -166,7 +168,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
raise PermissionDenied
|
||||
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
|
||||
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
|
||||
existing.notice_id != getattr(data.get('notice_id'), 'pk', None)):
|
||||
existing.notice_id != getattr(data.get('notice_id'), 'pk', None) or
|
||||
revision.depositor_name != fields['depositor_name']):
|
||||
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
|
||||
return existing
|
||||
notice = data.get('notice_id')
|
||||
@@ -178,7 +181,6 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
validate_destination(account, notice)
|
||||
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
|
||||
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
|
||||
fields = receipt_metadata(data)
|
||||
fields['destination'] = account
|
||||
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
|
||||
revision = PaymentReceiptRevision(receipt=receipt, version=1,
|
||||
@@ -226,7 +228,7 @@ def submit_receipt(actor, customer, data, *, access_link=None):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
def decide_receipt(actor, receipt_id, version, action, reason='', *, rejection_confirmed=True):
|
||||
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
|
||||
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
|
||||
# Never authorize a financial vote using a stale User/profile/permission cache.
|
||||
@@ -244,6 +246,8 @@ def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
|
||||
if not allowed:
|
||||
raise PermissionDenied
|
||||
if action == 'REJECT' and not rejection_confirmed:
|
||||
raise ValidationError('برای رد فیش، ارسال دلیل رد به مشتری را تأیید کنید.')
|
||||
reason = reason.strip()
|
||||
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
|
||||
raise ValidationError('دلیل این تصمیم را بنویسید.')
|
||||
@@ -277,6 +281,9 @@ def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
if status == 'APPROVED':
|
||||
from .notifications import queue_approval
|
||||
queue_approval(receipt, cycle)
|
||||
elif status == 'REJECTED':
|
||||
from .notifications import queue_rejection
|
||||
queue_rejection(receipt, cycle, reason)
|
||||
return receipt
|
||||
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ def receipt_form_page(request, customer, staff=False):
|
||||
image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
|
||||
ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True,
|
||||
'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer,
|
||||
'account_requirements': {str(a.pk): a.requires_deposit_id for a in form.fields['destination_account'].queryset}}
|
||||
'account_requirements': form.account_warnings, 'customer_deposit_id': form.customer_deposit_id}
|
||||
return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx))
|
||||
|
||||
|
||||
@@ -221,7 +221,8 @@ def decision(request, public_id):
|
||||
get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id)
|
||||
try:
|
||||
version = int(request.POST.get('version', '0'))
|
||||
receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', ''))
|
||||
receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', ''),
|
||||
rejection_confirmed=request.POST.get('confirm_rejection') == '1')
|
||||
messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display())
|
||||
except (ValidationError, ValueError) as error:
|
||||
messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.')
|
||||
@@ -505,8 +506,10 @@ def receipt_revise(request, public_id):
|
||||
receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED')
|
||||
import jdatetime
|
||||
current = receipt.revisions.get(version=receipt.current_version)
|
||||
from .identity import customer_name
|
||||
paid = jdatetime.date.fromgregorian(date=current.paid_date)
|
||||
initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id,
|
||||
'depositor_name':current.depositor_name or customer_name(request.payment_customer),
|
||||
'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id,
|
||||
'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year),
|
||||
'origin_bank':current.origin_bank_id,'origin_other':current.origin_other,
|
||||
@@ -529,5 +532,5 @@ def receipt_revise(request, public_id):
|
||||
notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)]
|
||||
return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0,
|
||||
'payment_link_access': link,
|
||||
'account_requirements':{str(a.pk):a.requires_deposit_id for a in form.fields['destination_account'].queryset},
|
||||
'account_requirements':form.account_warnings, 'customer_deposit_id':form.customer_deposit_id,
|
||||
'financial_page':True,'revision_version':receipt.current_version})
|
||||
Reference in new issue
Block a user