This commit is contained in:
anhduy-tech
2026-01-30 12:03:15 +07:00
parent f55a0bbfac
commit 69937da0b6
8 changed files with 669 additions and 66 deletions

Binary file not shown.

View File

@@ -53,5 +53,6 @@ urlpatterns = [
re_path('generate-document/$',views.generate_document),
re_path('model-fields/(?P<name>.+)/', importdata.model_fields),
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)
]

View File

@@ -3,24 +3,22 @@ from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.db import transaction
from datetime import datetime
from decimal import Decimal
from django.db.models import F
import threading
decimal = 0
#==========================================================================================
# ==========================================================================================
def getcode(code, Model):
try:
obj = Model.objects.latest('id')
val = 1 if obj == None else obj.id + 1
except Exception as e:
val = 1
length = len(str(val))
text = ''
for i in range(0, 6-length):
text += '0'
return '{}{}{}'.format(code, text, val)
try:
obj = Model.objects.latest('id')
val = 1 if obj is None else obj.id + 1
except Exception:
val = 1
length = len(str(val))
text = '0' * (6 - length)
return f"{code}{text}{val}"
#==========================================================================================
# ==========================================================================================
def account_entry_api(code, amount, content, type, category, userid, ref=None, product=None, customer=None, date=None):
try:
user = User.objects.get(id=userid)
@@ -34,10 +32,9 @@ def account_entry_api(code, amount, content, type, category, userid, ref=None, p
start_balance = account.balance or 0
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':
# account.balance = F('balance') + amount
account.balance += amount
else:
account.balance -= amount
@@ -58,72 +55,413 @@ def account_entry_api(code, amount, content, type, category, userid, ref=None, p
account=account,
date=system_date,
ref=ref,
product=None if product==None else Product.objects.get(id=product),
customer=None if customer==None else Customer.objects.get(id=customer)
product=None if product is None else Product.objects.get(id=product),
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'
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:
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:
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:
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:
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:
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'])
def account_entry(request):
print(request.data.get('date'))
ref = request.data.get('ref')
data = account_entry_api(
code=request.data['code'],
amount=request.data['amount'],
content=request.data['content'],
type=request.data['type'],
category=request.data['category'],
userid=request.data['user'],
ref=ref,
product=request.data['product'],
customer=request.data['customer'],
date=request.data.get('date')
)
if 'error' in data:
return Response(data, status=400)
return Response(data)
print(request.data.get('date'))
ref = request.data.get('ref')
response_data, created_entry = account_entry_api(
code=request.data['code'],
amount=request.data['amount'],
content=request.data['content'],
type=request.data['type'],
category=request.data['category'],
userid=request.data['user'],
ref=ref,
product=request.data['product'],
customer=request.data['customer'],
date=request.data.get('date')
)
#==========================================================================================
if 'error' in response_data:
return Response(response_data, status=400)
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'])
def account_multi_entry(request):
try:
result = []
data = request.data.get('data')
for obj in data:
row = account_entry_api(
code=obj['Tài khoản'],
amount=obj['amount'],
content=obj['content'],
type='CR',
category=obj['category'],
userid=request.data.get('user'),
ref=obj['ref'],
product=obj['product'],
customer=obj['customer'],
date=obj['date']
)
result.append(row)
entries_created = []
data_list = request.data.get('data', [])
with transaction.atomic():
for obj in data_list:
response_data, created_entry = account_entry_api(
code=obj['Tài khoản'],
amount=obj['amount'],
content=obj['content'],
type='CR',
category=obj['category'],
userid=request.data.get('user'),
ref=obj['ref'],
product=obj['product'],
customer=obj['customer'],
date=obj['date']
)
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..."
})
return Response(result)
except Exception as 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)

View File

@@ -27,6 +27,8 @@ from concurrent.futures import ThreadPoolExecutor
import json
import io
import pandas as pd
import numpy as np
from num2words import num2words
from django.db import transaction
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist
@@ -1347,4 +1349,266 @@ def generate_document(request):
time.sleep(5)
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
)