changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -53,5 +53,6 @@ urlpatterns = [
|
|||||||
re_path('generate-document/$',views.generate_document),
|
re_path('generate-document/$',views.generate_document),
|
||||||
re_path('model-fields/(?P<name>.+)/', importdata.model_fields),
|
re_path('model-fields/(?P<name>.+)/', importdata.model_fields),
|
||||||
re_path('read-excel/', importdata.read_excel),
|
re_path('read-excel/', importdata.read_excel),
|
||||||
re_path('find-key/$', importdata.find_key)
|
re_path('find-key/$', importdata.find_key),
|
||||||
|
re_path('email-preview/$', views.preview_email_template)
|
||||||
]
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
396
app/payment.py
396
app/payment.py
@@ -3,22 +3,20 @@ from rest_framework.decorators import api_view
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db.models import F
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
decimal = 0
|
|
||||||
# ==========================================================================================
|
# ==========================================================================================
|
||||||
def getcode(code, Model):
|
def getcode(code, Model):
|
||||||
try:
|
try:
|
||||||
obj = Model.objects.latest('id')
|
obj = Model.objects.latest('id')
|
||||||
val = 1 if obj == None else obj.id + 1
|
val = 1 if obj is None else obj.id + 1
|
||||||
except Exception as e:
|
except Exception:
|
||||||
val = 1
|
val = 1
|
||||||
length = len(str(val))
|
length = len(str(val))
|
||||||
text = ''
|
text = '0' * (6 - length)
|
||||||
for i in range(0, 6-length):
|
return f"{code}{text}{val}"
|
||||||
text += '0'
|
|
||||||
return '{}{}{}'.format(code, text, val)
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================================
|
# ==========================================================================================
|
||||||
def account_entry_api(code, amount, content, type, category, userid, ref=None, product=None, customer=None, date=None):
|
def account_entry_api(code, amount, content, type, category, userid, ref=None, product=None, customer=None, date=None):
|
||||||
@@ -34,10 +32,9 @@ def account_entry_api(code, amount, content, type, category, userid, ref=None, p
|
|||||||
start_balance = account.balance or 0
|
start_balance = account.balance or 0
|
||||||
|
|
||||||
if entry_type.code == 'DR' and start_balance < amount:
|
if entry_type.code == 'DR' and start_balance < amount:
|
||||||
return {'error': 'Số dư không đủ để thực hiện giao dịch.'}
|
return {'error': 'Số dư không đủ để thực hiện giao dịch.'}, None
|
||||||
|
|
||||||
if entry_type.code == 'CR':
|
if entry_type.code == 'CR':
|
||||||
# account.balance = F('balance') + amount
|
|
||||||
account.balance += amount
|
account.balance += amount
|
||||||
else:
|
else:
|
||||||
account.balance -= amount
|
account.balance -= amount
|
||||||
@@ -58,31 +55,342 @@ def account_entry_api(code, amount, content, type, category, userid, ref=None, p
|
|||||||
account=account,
|
account=account,
|
||||||
date=system_date,
|
date=system_date,
|
||||||
ref=ref,
|
ref=ref,
|
||||||
product=None if product==None else Product.objects.get(id=product),
|
product=None if product is None else Product.objects.get(id=product),
|
||||||
customer=None if customer==None else Customer.objects.get(id=customer)
|
customer=None if customer is None else Customer.objects.get(id=customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
text = 'id,account__currency__code,ref,balance_before,balance_after,code,account,account__code,account__branch__name,account__type__name,date,amount,content,inputer,inputer__fullname,approver,approver__fullname,create_time,update_time,type,type__code,type__name'
|
text = 'id,account__currency__code,ref,balance_before,balance_after,code,account,account__code,account__branch__name,account__type__name,date,amount,content,inputer,inputer__fullname,approver,approver__fullname,create_time,update_time,type,type__code,type__name'
|
||||||
fields = text.split(',')
|
fields = text.split(',')
|
||||||
return Internal_Entry.objects.filter(id=entry.id).values(*fields).first()
|
response_data = Internal_Entry.objects.filter(id=entry.id).values(*fields).first()
|
||||||
|
return response_data, entry
|
||||||
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return {'error': f"Người dùng với ID {userid} không tồn tại."}
|
return {'error': f"Người dùng với ID {userid} không tồn tại."}, None
|
||||||
except Internal_Account.DoesNotExist:
|
except Internal_Account.DoesNotExist:
|
||||||
return {'error': f"Tài khoản nội bộ với mã '{code}' không tồn tại."}
|
return {'error': f"Tài khoản nội bộ với mã '{code}' không tồn tại."}, None
|
||||||
except Entry_Type.DoesNotExist:
|
except Entry_Type.DoesNotExist:
|
||||||
return {'error': f"Loại bút toán với mã '{type}' không tồn tại."}
|
return {'error': f"Loại bút toán với mã '{type}' không tồn tại."}, None
|
||||||
except Entry_Category.DoesNotExist:
|
except Entry_Category.DoesNotExist:
|
||||||
return {'error': f"Danh mục bút toán với ID '{category}' không tồn tại."}
|
return {'error': f"Danh mục bút toán với ID '{category}' không tồn tại."}, None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"}
|
return {'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"}, None
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# HÀM LẤY RULE TỪ BIZ_SETTING (detail là string)
|
||||||
|
# ==========================================================================================
|
||||||
|
def get_allocation_rule():
|
||||||
|
try:
|
||||||
|
rule_setting = Biz_Setting.objects.get(code='rule')
|
||||||
|
rule_value = (rule_setting.detail or 'principal-fee').strip()
|
||||||
|
|
||||||
|
if rule_value.lower() in ['fee-principal', 'phạt trước', 'phat truoc']:
|
||||||
|
return 'fee-principal'
|
||||||
|
else:
|
||||||
|
return 'principal-fee'
|
||||||
|
|
||||||
|
except Biz_Setting.DoesNotExist:
|
||||||
|
return 'principal-fee'
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# HÀM PHÂN BỔ THÔNG THƯỜNG
|
||||||
|
# ==========================================================================================
|
||||||
|
def allocate_payment_to_schedules(entries):
|
||||||
|
if not entries:
|
||||||
|
return {"status": "no_entries", "message": "Không có bút toán"}
|
||||||
|
|
||||||
|
allocation_rule = get_allocation_rule()
|
||||||
|
updated_schedules = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Lấy status "đã thanh toán" một lần
|
||||||
|
paid_payment_status = None
|
||||||
|
paid_txn_status = None
|
||||||
|
try:
|
||||||
|
paid_payment_status = Payment_Status.objects.get(id=2)
|
||||||
|
except Payment_Status.DoesNotExist:
|
||||||
|
errors.append("Không tìm thấy Payment_Status id=2 (đã thanh toán)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
paid_txn_status = Transaction_Status.objects.get(id=2)
|
||||||
|
except Transaction_Status.DoesNotExist:
|
||||||
|
errors.append("Không tìm thấy Transaction_Status id=2 (đã thanh toán)")
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.type.code != 'CR' or entry.account.id == 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
amount = Decimal(str(entry.amount))
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
booked = Product_Booked.objects.filter(product=entry.product).first()
|
||||||
|
if not booked or not booked.transaction:
|
||||||
|
errors.append(f"Entry {entry.code}: Không tìm thấy Transaction")
|
||||||
|
continue
|
||||||
|
|
||||||
|
txn = booked.transaction
|
||||||
|
|
||||||
|
txn_detail = None
|
||||||
|
try:
|
||||||
|
current = Transaction_Current.objects.get(transaction=txn)
|
||||||
|
txn_detail = current.detail
|
||||||
|
except (Transaction_Current.DoesNotExist, AttributeError):
|
||||||
|
txn_detail = Transaction_Detail.objects.filter(
|
||||||
|
transaction=txn
|
||||||
|
).order_by('-create_time').first()
|
||||||
|
|
||||||
|
if not txn_detail:
|
||||||
|
errors.append(f"Entry {entry.code}: Không tìm thấy Transaction_Detail")
|
||||||
|
continue
|
||||||
|
|
||||||
|
schedules = Payment_Schedule.objects.filter(
|
||||||
|
txn_detail=txn_detail,
|
||||||
|
status__id=1 # giả sử status=1 là chưa thanh toán
|
||||||
|
).order_by('cycle', 'from_date')
|
||||||
|
|
||||||
|
if not schedules.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining = amount
|
||||||
|
total_allocated = Decimal('0')
|
||||||
|
|
||||||
|
for sch in schedules:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
penalty_remain = Decimal(str(sch.penalty_remain or 0))
|
||||||
|
amount_remain = Decimal(str(sch.amount_remain or 0))
|
||||||
|
paid_amount = Decimal(str(sch.paid_amount or 0))
|
||||||
|
penalty_paid = Decimal(str(sch.penalty_paid or 0))
|
||||||
|
remain_amount = Decimal(str(sch.remain_amount or 0))
|
||||||
|
|
||||||
|
to_penalty = Decimal('0')
|
||||||
|
to_principal = Decimal('0')
|
||||||
|
|
||||||
|
if allocation_rule == 'fee-principal':
|
||||||
|
to_penalty = min(remaining, penalty_remain)
|
||||||
|
remaining -= to_penalty
|
||||||
|
penalty_paid += to_penalty
|
||||||
|
penalty_remain -= to_penalty
|
||||||
|
to_principal = min(remaining, amount_remain)
|
||||||
|
remaining -= to_principal
|
||||||
|
paid_amount += to_principal
|
||||||
|
amount_remain -= to_principal
|
||||||
|
else:
|
||||||
|
to_principal = min(remaining, amount_remain)
|
||||||
|
remaining -= to_principal
|
||||||
|
paid_amount += to_principal
|
||||||
|
amount_remain -= to_principal
|
||||||
|
to_penalty = min(remaining, penalty_remain)
|
||||||
|
remaining -= to_penalty
|
||||||
|
penalty_paid += to_penalty
|
||||||
|
penalty_remain -= to_penalty
|
||||||
|
|
||||||
|
allocated_here = to_penalty + to_principal
|
||||||
|
total_allocated += allocated_here
|
||||||
|
|
||||||
|
sch.paid_amount = paid_amount
|
||||||
|
sch.penalty_paid = penalty_paid
|
||||||
|
sch.amount_remain = amount_remain
|
||||||
|
sch.penalty_remain = penalty_remain
|
||||||
|
sch.remain_amount = max(Decimal('0'), remain_amount - allocated_here)
|
||||||
|
|
||||||
|
entry_list = sch.entry or []
|
||||||
|
|
||||||
|
date_value = entry.date
|
||||||
|
if hasattr(date_value, 'isoformat'):
|
||||||
|
date_value = date_value.isoformat()
|
||||||
|
else:
|
||||||
|
date_value = str(date_value)
|
||||||
|
|
||||||
|
entry_list.append({
|
||||||
|
"code": entry.code,
|
||||||
|
"amount": float(allocated_here),
|
||||||
|
"date": date_value,
|
||||||
|
"type": "CR",
|
||||||
|
"principal": float(to_principal),
|
||||||
|
"penalty": float(to_penalty),
|
||||||
|
"rule": allocation_rule
|
||||||
|
})
|
||||||
|
sch.entry = entry_list
|
||||||
|
|
||||||
|
if sch.amount_remain <= 0 and sch.penalty_remain <= 0 and paid_payment_status:
|
||||||
|
sch.status = paid_payment_status
|
||||||
|
|
||||||
|
sch.save(update_fields=[
|
||||||
|
'paid_amount', 'penalty_paid', 'amount_remain',
|
||||||
|
'penalty_remain', 'remain_amount', 'entry', 'status'
|
||||||
|
])
|
||||||
|
|
||||||
|
updated_schedules.append(sch.id)
|
||||||
|
|
||||||
|
# Cập nhật Transaction_Detail
|
||||||
|
txn_detail.amount_received = F('amount_received') + total_allocated
|
||||||
|
txn_detail.amount_remaining = F('amount_remaining') - total_allocated
|
||||||
|
txn_detail.save()
|
||||||
|
txn_detail.refresh_from_db()
|
||||||
|
|
||||||
|
if txn_detail.amount_remaining <= 0 and paid_txn_status:
|
||||||
|
txn_detail.status = paid_txn_status
|
||||||
|
txn_detail.save(update_fields=['status'])
|
||||||
|
|
||||||
|
# Cập nhật Transaction
|
||||||
|
txn.amount_received = F('amount_received') + total_allocated
|
||||||
|
txn.amount_remain = F('amount_remain') - total_allocated
|
||||||
|
txn.save()
|
||||||
|
txn.refresh_from_db()
|
||||||
|
|
||||||
|
if txn.amount_remain <= 0 and paid_txn_status:
|
||||||
|
txn.status = paid_txn_status
|
||||||
|
txn.save(update_fields=['status'])
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"Entry {entry.code}: Lỗi phân bổ - {str(exc)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success" if not errors else "partial_failure",
|
||||||
|
"updated_schedules": updated_schedules,
|
||||||
|
"errors": errors,
|
||||||
|
"rule_used": allocation_rule
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# HÀM MIỄN LÃI
|
||||||
|
# ==========================================================================================
|
||||||
|
def allocate_penalty_reduction(entries):
|
||||||
|
if not entries:
|
||||||
|
return {"status": "no_entries", "message": "Không có bút toán"}
|
||||||
|
|
||||||
|
updated_schedules = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.type.code != 'CR' or entry.account.id != 5:
|
||||||
|
continue # Chỉ xử lý bút toán từ tài khoản miễn lãi
|
||||||
|
|
||||||
|
amount = Decimal(str(entry.amount))
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
booked = Product_Booked.objects.filter(product=entry.product).first()
|
||||||
|
if not booked or not booked.transaction:
|
||||||
|
errors.append(f"Entry {entry.code}: Không tìm thấy Transaction")
|
||||||
|
continue
|
||||||
|
|
||||||
|
txn = booked.transaction
|
||||||
|
|
||||||
|
txn_detail = None
|
||||||
|
try:
|
||||||
|
current = Transaction_Current.objects.get(transaction=txn)
|
||||||
|
txn_detail = current.detail
|
||||||
|
except (Transaction_Current.DoesNotExist, AttributeError):
|
||||||
|
txn_detail = Transaction_Detail.objects.filter(
|
||||||
|
transaction=txn
|
||||||
|
).order_by('-create_time').first()
|
||||||
|
|
||||||
|
if not txn_detail:
|
||||||
|
errors.append(f"Entry {entry.code}: Không tìm thấy Transaction_Detail")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Lấy các lịch chưa thanh toán (status=1)
|
||||||
|
schedules = Payment_Schedule.objects.filter(
|
||||||
|
txn_detail=txn_detail,
|
||||||
|
status=1
|
||||||
|
).order_by('cycle', 'from_date')
|
||||||
|
|
||||||
|
if not schedules.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining_reduce = amount # số tiền miễn lãi còn lại để phân bổ
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
if remaining_reduce <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
current_penalty_remain = Decimal(str(schedule.penalty_remain or 0))
|
||||||
|
current_penalty_reduce = Decimal(str(schedule.penalty_reduce or 0))
|
||||||
|
current_remain_amount = Decimal(str(schedule.remain_amount or 0))
|
||||||
|
|
||||||
|
# Chỉ miễn tối đa bằng số phạt còn lại
|
||||||
|
to_reduce = min(remaining_reduce, current_penalty_remain)
|
||||||
|
remaining_reduce -= to_reduce
|
||||||
|
|
||||||
|
# Cập nhật các trường
|
||||||
|
schedule.penalty_reduce = current_penalty_reduce + to_reduce
|
||||||
|
schedule.penalty_remain = current_penalty_remain - to_reduce
|
||||||
|
|
||||||
|
# GIẢM TỔNG CÒN LẠI (remain_amount)
|
||||||
|
schedule.remain_amount = max(Decimal('0'), current_remain_amount - to_reduce)
|
||||||
|
|
||||||
|
# KHÔNG ĐỘNG ĐẾN amount_remain (nợ gốc còn lại)
|
||||||
|
|
||||||
|
# Ghi trace bút toán miễn lãi
|
||||||
|
entry_list = schedule.entry or []
|
||||||
|
|
||||||
|
date_value = entry.date
|
||||||
|
if hasattr(date_value, 'isoformat'):
|
||||||
|
date_value = date_value.isoformat()
|
||||||
|
else:
|
||||||
|
date_value = str(date_value)
|
||||||
|
|
||||||
|
entry_list.append({
|
||||||
|
"code": entry.code,
|
||||||
|
"amount": float(to_reduce),
|
||||||
|
"date": date_value,
|
||||||
|
"type": "REDUCTION",
|
||||||
|
"note": "Miễn lãi phạt quá hạn"
|
||||||
|
})
|
||||||
|
schedule.entry = entry_list
|
||||||
|
|
||||||
|
# Lưu lại
|
||||||
|
schedule.save(update_fields=[
|
||||||
|
'penalty_reduce', 'penalty_remain', 'remain_amount', 'entry'
|
||||||
|
])
|
||||||
|
|
||||||
|
updated_schedules.append(schedule.id)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"Entry {entry.code}: Lỗi miễn lãi - {str(exc)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success" if not errors else "partial_failure",
|
||||||
|
"updated_schedules": updated_schedules,
|
||||||
|
"errors": errors,
|
||||||
|
"message": f"Đã miễn lãi cho {len(updated_schedules)} lịch thanh toán (chỉ giảm phạt + tổng còn lại)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# BACKGROUND FUNCTION
|
||||||
|
# ==========================================================================================
|
||||||
|
def background_allocate(entries_created):
|
||||||
|
try:
|
||||||
|
# Debug type date
|
||||||
|
for e in entries_created:
|
||||||
|
print(f"Debug entry {e.code}: date type = {type(e.date)}, value = {e.date}")
|
||||||
|
|
||||||
|
normal_result = allocate_payment_to_schedules(entries_created)
|
||||||
|
reduction_result = allocate_penalty_reduction(entries_created)
|
||||||
|
|
||||||
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation completed:")
|
||||||
|
print("Normal:", normal_result)
|
||||||
|
print("Reduction:", reduction_result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation error: {str(e)}")
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# API TẠO MỘT BÚT TOÁN
|
||||||
# ==========================================================================================
|
# ==========================================================================================
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def account_entry(request):
|
def account_entry(request):
|
||||||
print(request.data.get('date'))
|
print(request.data.get('date'))
|
||||||
ref = request.data.get('ref')
|
ref = request.data.get('ref')
|
||||||
data = account_entry_api(
|
|
||||||
|
response_data, created_entry = account_entry_api(
|
||||||
code=request.data['code'],
|
code=request.data['code'],
|
||||||
amount=request.data['amount'],
|
amount=request.data['amount'],
|
||||||
content=request.data['content'],
|
content=request.data['content'],
|
||||||
@@ -95,20 +403,35 @@ def account_entry(request):
|
|||||||
date=request.data.get('date')
|
date=request.data.get('date')
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'error' in data:
|
if 'error' in response_data:
|
||||||
return Response(data, status=400)
|
return Response(response_data, status=400)
|
||||||
|
|
||||||
return Response(data)
|
if created_entry:
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=background_allocate,
|
||||||
|
args=([created_entry],),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
**response_data,
|
||||||
|
"message": "Bút toán đã tạo thành công. Phân bổ thanh toán đang chạy ngầm..."
|
||||||
|
})
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# API TẠO NHIỀU BÚT TOÁN
|
||||||
# ==========================================================================================
|
# ==========================================================================================
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def account_multi_entry(request):
|
def account_multi_entry(request):
|
||||||
try:
|
try:
|
||||||
result = []
|
result = []
|
||||||
data = request.data.get('data')
|
entries_created = []
|
||||||
for obj in data:
|
data_list = request.data.get('data', [])
|
||||||
row = account_entry_api(
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for obj in data_list:
|
||||||
|
response_data, created_entry = account_entry_api(
|
||||||
code=obj['Tài khoản'],
|
code=obj['Tài khoản'],
|
||||||
amount=obj['amount'],
|
amount=obj['amount'],
|
||||||
content=obj['content'],
|
content=obj['content'],
|
||||||
@@ -120,10 +443,25 @@ def account_multi_entry(request):
|
|||||||
customer=obj['customer'],
|
customer=obj['customer'],
|
||||||
date=obj['date']
|
date=obj['date']
|
||||||
)
|
)
|
||||||
result.append(row)
|
|
||||||
|
|
||||||
return Response(result)
|
result.append(response_data)
|
||||||
|
|
||||||
|
if created_entry:
|
||||||
|
entries_created.append(created_entry)
|
||||||
|
|
||||||
|
if entries_created:
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=background_allocate,
|
||||||
|
args=(entries_created,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"entries": result,
|
||||||
|
"message": "Bút toán đã tạo thành công. Phân bổ thanh toán đang chạy ngầm..."
|
||||||
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print({'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"})
|
print({'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"})
|
||||||
return Response(data, status=400)
|
return Response({'error': str(e)}, status=400)
|
||||||
264
app/views.py
264
app/views.py
@@ -27,6 +27,8 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
import json
|
import json
|
||||||
import io
|
import io
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from num2words import num2words
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
@@ -1348,3 +1350,265 @@ def generate_document(request):
|
|||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
return Response({"error": "Timeout chờ generate."}, status=status.HTTP_408_REQUEST_TIMEOUT)
|
return Response({"error": "Timeout chờ generate."}, status=status.HTTP_408_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
#=============================================================================
|
||||||
|
# EMAIL TEMPLATE PREVIEW
|
||||||
|
#=============================================================================
|
||||||
|
class EmailTemplatePreview:
|
||||||
|
"""
|
||||||
|
Class để preview nội dung email template với dữ liệu đã được map
|
||||||
|
Không gửi email, chỉ trả về nội dung đã render
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, template, context_pks: dict):
|
||||||
|
self.template = template
|
||||||
|
self.context_pks = context_pks
|
||||||
|
self.config = self.template.content
|
||||||
|
self.data_context = {}
|
||||||
|
self.replacements = {}
|
||||||
|
|
||||||
|
def _get_model(self, model_string):
|
||||||
|
"""Lấy model class từ string 'app.Model'"""
|
||||||
|
app_label, model_name = model_string.split(".")
|
||||||
|
return apps.get_model(app_label, model_name)
|
||||||
|
|
||||||
|
def _get_value_from_object(self, obj, field_path):
|
||||||
|
"""Lấy giá trị từ object theo field path (hỗ trợ nested: 'user.profile.name')"""
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
value = obj
|
||||||
|
for part in field_path.replace("__", ".").split("."):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = getattr(value, part, None)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _resolve_lookup_value(self, lookup_from):
|
||||||
|
"""Resolve giá trị lookup từ context_pks hoặc data_context"""
|
||||||
|
if lookup_from in self.context_pks:
|
||||||
|
return self.context_pks[lookup_from]
|
||||||
|
|
||||||
|
try:
|
||||||
|
alias, field_path = lookup_from.split(".", 1)
|
||||||
|
if alias not in self.data_context:
|
||||||
|
raise ValueError(f"Alias '{alias}' not found in data context.")
|
||||||
|
|
||||||
|
source_object = self.data_context.get(alias)
|
||||||
|
return self._get_value_from_object(source_object, field_path)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Could not resolve '{lookup_from}'.")
|
||||||
|
|
||||||
|
def fetch_data(self):
|
||||||
|
"""Fetch data từ database theo mappings config"""
|
||||||
|
mappings = self.config.get("mappings", [])
|
||||||
|
if not isinstance(mappings, list):
|
||||||
|
raise TypeError("Email template 'mappings' must be a list.")
|
||||||
|
|
||||||
|
# Process trigger object first
|
||||||
|
trigger_model_mapping = next((m for m in mappings if m.get("is_trigger_object", False)), None)
|
||||||
|
if trigger_model_mapping:
|
||||||
|
model_cls = self._get_model(trigger_model_mapping["model"])
|
||||||
|
lookup_field = trigger_model_mapping["lookup_field"]
|
||||||
|
lookup_value = self._resolve_lookup_value(trigger_model_mapping["lookup_value_from"])
|
||||||
|
alias = trigger_model_mapping["alias"]
|
||||||
|
if lookup_value is not None:
|
||||||
|
self.data_context[alias] = model_cls.objects.filter(**{lookup_field: lookup_value}).first()
|
||||||
|
else:
|
||||||
|
self.data_context[alias] = None
|
||||||
|
|
||||||
|
# Process other mappings
|
||||||
|
for mapping in mappings:
|
||||||
|
if mapping.get("is_trigger_object", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_cls = self._get_model(mapping["model"])
|
||||||
|
lookup_field = mapping["lookup_field"]
|
||||||
|
lookup_value = self._resolve_lookup_value(mapping["lookup_value_from"])
|
||||||
|
alias = mapping["alias"]
|
||||||
|
|
||||||
|
if lookup_value is None:
|
||||||
|
self.data_context[alias] = None if mapping.get("type") == "object" else []
|
||||||
|
continue
|
||||||
|
|
||||||
|
queryset = model_cls.objects.filter(**{lookup_field: lookup_value})
|
||||||
|
|
||||||
|
if mapping.get("type") == "object":
|
||||||
|
self.data_context[alias] = queryset.first()
|
||||||
|
elif mapping.get("type") == "list":
|
||||||
|
self.data_context[alias] = list(queryset)
|
||||||
|
|
||||||
|
def _format_value(self, value, format_config):
|
||||||
|
"""Format value theo config (currency, date, number_to_words, conditional)"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
format_type = format_config.get("type")
|
||||||
|
if not format_type:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if format_type == "currency":
|
||||||
|
return "{:,}".format(np.int64(value)).replace(",", ".")
|
||||||
|
if format_type == "date":
|
||||||
|
date_format = format_config.get("format", "dd/mm/YYYY").replace("dd", "%d").replace("mm", "%m").replace("YYYY", "%Y")
|
||||||
|
return value.strftime(date_format)
|
||||||
|
if format_type == "number_to_words":
|
||||||
|
return num2words(value, lang=format_config.get("lang", "vi"))
|
||||||
|
if format_type == "conditional":
|
||||||
|
return format_config["true_value"] if value else format_config["false_value"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error formatting value '{value}' with config '{format_config}': {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def prepare_replacements(self):
|
||||||
|
"""Chuẩn bị dict replacements cho placeholders"""
|
||||||
|
# Add date placeholders
|
||||||
|
today = datetime.now()
|
||||||
|
self.replacements['[day]'] = str(today.day)
|
||||||
|
self.replacements['[month]'] = str(today.month)
|
||||||
|
self.replacements['[year]'] = str(today.year)
|
||||||
|
self.replacements['[date]'] = today.strftime("%d/%m/%Y")
|
||||||
|
|
||||||
|
# Process field mappings
|
||||||
|
mappings = self.config.get("mappings", [])
|
||||||
|
for mapping in mappings:
|
||||||
|
alias = mapping["alias"]
|
||||||
|
data = self.data_context.get(alias)
|
||||||
|
fields = mapping.get("fields", {})
|
||||||
|
|
||||||
|
if mapping.get("type") == "object":
|
||||||
|
if data is None:
|
||||||
|
for placeholder in fields:
|
||||||
|
self.replacements[placeholder] = ""
|
||||||
|
continue
|
||||||
|
|
||||||
|
for placeholder, config in fields.items():
|
||||||
|
if isinstance(config, dict):
|
||||||
|
value = self._get_value_from_object(data, config["source"])
|
||||||
|
self.replacements[placeholder] = self._format_value(value, config.get("format", {}))
|
||||||
|
else:
|
||||||
|
value = self._get_value_from_object(data, config)
|
||||||
|
self.replacements[placeholder] = str(value) if value is not None else ""
|
||||||
|
|
||||||
|
def get_preview(self):
|
||||||
|
"""
|
||||||
|
Main method để lấy preview email content - trả về nội dung y nguyên đã thay thế placeholders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'subject': subject đã thay thế placeholders,
|
||||||
|
'content': body content đã thay thế placeholders (giữ nguyên format, căn lề),
|
||||||
|
'recipient_email': email người nhận,
|
||||||
|
'replacements': dict của tất cả replacements được áp dụng
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"Generating preview for template: {self.template.name}")
|
||||||
|
|
||||||
|
# Fetch data and prepare replacements
|
||||||
|
self.fetch_data()
|
||||||
|
self.prepare_replacements()
|
||||||
|
|
||||||
|
# Get templates from config
|
||||||
|
subject_template = self.config.get("subject", "")
|
||||||
|
body_template = self.config.get("content", "")
|
||||||
|
recipient_placeholder = self.config.get("recipient_placeholder", "[customer.email]")
|
||||||
|
|
||||||
|
# Apply replacements - giữ nguyên format, căn lề của template gốc
|
||||||
|
final_subject = subject_template
|
||||||
|
final_content = body_template
|
||||||
|
|
||||||
|
for key, value in self.replacements.items():
|
||||||
|
final_subject = final_subject.replace(key, str(value))
|
||||||
|
final_content = final_content.replace(key, str(value))
|
||||||
|
|
||||||
|
recipient_email = self.replacements.get(recipient_placeholder, "")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'subject': final_subject,
|
||||||
|
'content': final_content, # Nội dung y nguyên đã thay thế
|
||||||
|
'recipient_email': recipient_email,
|
||||||
|
'replacements': self.replacements.copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Preview generated successfully for '{self.template.name}'")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating preview for template '{self.template.name}': {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def preview_email_template(request):
|
||||||
|
"""
|
||||||
|
API để preview email template - trả về nội dung đã thay thế placeholders
|
||||||
|
|
||||||
|
POST /api/email/preview/
|
||||||
|
Body: {
|
||||||
|
"template_id": 1,
|
||||||
|
"context_pks": {
|
||||||
|
"contract_id": 456,
|
||||||
|
"customer_id": 789
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: {
|
||||||
|
"subject": "Thông báo hợp đồng #HD-001",
|
||||||
|
"content": "<div>Nội dung y nguyên đã thay thế...</div>",
|
||||||
|
"recipient_email": "customer@example.com",
|
||||||
|
"replacements": {
|
||||||
|
"[contract.code]": "HD-001",
|
||||||
|
"[customer.name]": "Nguyễn Văn A",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate input
|
||||||
|
template_id = request.data.get('template_id')
|
||||||
|
if not template_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'template_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
context_pks = request.data.get('context_pks', {})
|
||||||
|
if not isinstance(context_pks, dict):
|
||||||
|
return Response(
|
||||||
|
{'error': 'context_pks must be a dictionary'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get template
|
||||||
|
try:
|
||||||
|
template = Email_Template.objects.get(pk=template_id)
|
||||||
|
except Email_Template.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Email_Template with id={template_id} does not exist'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
previewer = EmailTemplatePreview(template, context_pks)
|
||||||
|
preview = previewer.get_preview()
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
return Response(preview, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to generate preview'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return Response(
|
||||||
|
{'error': f'Unexpected error: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user