fix fish variz name, dalilrad, sms, bale

This commit is contained in:
javad committed 2026-10-11 15:19:58 +03:30
1 parent 90931a5a64
commit 35a1ec1794
30 files changed
+674 -39

No files matched your search

+10
View File
@@ -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)
+28
View File
@@ -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,
),
]
+1
View File
@@ -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)
+21 -5
View File
@@ -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
+5 -3
View File
@@ -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
+1
View File
@@ -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]))
+11 -4
View File
@@ -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
+6 -3
View File
@@ -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})