This commit is contained in:
anhduy-tech
2026-02-04 21:01:54 +07:00
parent f297a5e1b4
commit 5b360753d8
11 changed files with 552 additions and 204 deletions

Binary file not shown.

View File

@@ -54,5 +54,6 @@ urlpatterns = [
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('email-preview/$', views.preview_email_template)
re_path('email-preview/$', views.preview_email_template),
re_path('delete-entry/$', payment.delete_entry)
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2026-02-02 01:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0373_remove_transaction_ovd_days_and_more'),
]
operations = [
migrations.AddField(
model_name='payment_schedule',
name='link',
field=models.CharField(max_length=100, null=True),
),
migrations.AddField(
model_name='payment_schedule',
name='ref_code',
field=models.CharField(max_length=30, null=True),
),
]

View File

@@ -1694,6 +1694,8 @@ class Payment_Schedule(AutoCodeModel):
code_prefix = "SH"
code_padding = 5
code = models.CharField(max_length=30, null=True, unique=True)
link = models.CharField(max_length=100, null=True)
ref_code = models.CharField(max_length=30, null=True)
from_date = models.DateField(null=False)
to_date = models.DateField(null=False)
amount = models.DecimalField(max_digits=35, decimal_places=2)

View File

@@ -2,10 +2,55 @@ from app.models import *
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.db import transaction
from datetime import datetime
from datetime import datetime, date
from decimal import Decimal
from django.db.models import F
import threading
import json
# ==========================================================================================
# HELPER FUNCTIONS
# ==========================================================================================
def safe_json_serialize(obj):
"""Serialize an toàn cho JSONField"""
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, dict):
return {k: safe_json_serialize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [safe_json_serialize(item) for item in obj]
return obj
def get_latest_payment_date(schedule):
"""Lấy ngày nộp gần nhất từ entry (type='PAYMENT')"""
if not schedule.entry:
return None
entry_data = schedule.entry
if isinstance(entry_data, str):
try:
entry_data = json.loads(entry_data)
except json.JSONDecodeError:
return None
if not isinstance(entry_data, list):
return None
payment_dates = []
for item in entry_data:
if item.get('type') == 'PAYMENT' and item.get('date'):
try:
dt = datetime.strptime(item['date'], "%Y-%m-%d").date()
payment_dates.append(dt)
except:
pass
if payment_dates:
return max(payment_dates)
return None
# ==========================================================================================
def getcode(code, Model):
@@ -18,6 +63,7 @@ def getcode(code, Model):
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:
@@ -43,7 +89,6 @@ def account_entry_api(code, amount, content, type, category, userid, ref=None, p
account.refresh_from_db()
new_balance = account.balance
# Tất cả entry CR đều có allocation_remain ban đầu = amount
entry = Internal_Entry.objects.create(
category=entry_category,
content=content,
@@ -79,93 +124,173 @@ def account_entry_api(code, amount, content, type, category, userid, ref=None, p
except Exception as 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Ổ THEO PRODUCT_ID - QUÉT LI TOÀN BỘ ENTRY CŨ CÓ TIỀN THỪA
# LOGIC TÍNH LÃI ĐÚNG - CÁCH ĐƠN GIẢN NHẤT
# ==========================================================================================
def recalculate_penalty_amount(schedule, up_to_date=None):
"""
TÍNH LẠI TOÀN BỘ LÃI PHẠT từ đầu đến ngày chỉ định
Logic:
1. Quét tất cả các PAYMENT entry để xây dựng timeline
2. Tính lãi cho từng khoảng thời gian với gốc tương ứng
3. Trả về tổng lãi (bao gồm cả lãi đã trả + lãi còn lại)
Đây là cách DUY NHẤT để tránh cộng dồn sai!
"""
if up_to_date is None:
up_to_date = datetime.now().date()
elif isinstance(up_to_date, str):
up_to_date = datetime.strptime(up_to_date, "%Y-%m-%d").date()
to_date = schedule.to_date
if isinstance(to_date, datetime):
to_date = to_date.date()
# Nếu chưa quá hạn
if up_to_date <= to_date:
return Decimal('0')
# Xây dựng timeline: [(date, gốc còn lại sau ngày đó)]
timeline = []
# Lấy tất cả PAYMENT entries, sắp xếp theo thời gian
entry_data = schedule.entry or []
if isinstance(entry_data, str):
try:
entry_data = json.loads(entry_data)
except json.JSONDecodeError:
entry_data = []
payments = [e for e in entry_data if e.get('type') == 'PAYMENT']
payments.sort(key=lambda x: x.get('date', ''))
# Điểm bắt đầu: to_date với gốc ban đầu
original_amount = Decimal(str(schedule.amount or 0))
current_principal = original_amount
timeline.append((to_date, current_principal))
# Thêm các điểm thanh toán
for payment in payments:
payment_date = datetime.strptime(payment['date'], "%Y-%m-%d").date()
principal_paid = Decimal(str(payment.get('principal', 0)))
current_principal -= principal_paid
if current_principal < 0:
current_principal = Decimal('0')
timeline.append((payment_date, current_principal))
# Tính lãi cho từng khoảng
total_penalty = Decimal('0')
for i in range(len(timeline)):
start_date, principal = timeline[i]
# Xác định ngày kết thúc của khoảng này
if i < len(timeline) - 1:
end_date = timeline[i + 1][0]
else:
end_date = up_to_date
# Tính số ngày và lãi
days = (end_date - start_date).days
if days > 0 and principal > 0:
penalty = principal * Decimal('0.0005') * Decimal(days)
penalty = penalty.quantize(Decimal('0.01'))
total_penalty += penalty
return total_penalty
def update_penalty_after_allocation(schedule):
"""
Cập nhật lãi phạt SAU KHI đã phân bổ xong
Gọi hàm này SAU KHI đã cập nhật paid_amount, amount_remain
"""
today = datetime.now().date()
# Tính lại TOÀN BỘ lãi từ đầu đến giờ
total_penalty = recalculate_penalty_amount(schedule, up_to_date=today)
# Cập nhật
schedule.penalty_amount = total_penalty
penalty_paid = Decimal(str(schedule.penalty_paid or 0))
schedule.penalty_remain = total_penalty - penalty_paid
schedule.remain_amount = Decimal(str(schedule.amount_remain or 0)) + schedule.penalty_remain
schedule.batch_date = today
# Lưu trace
entry_list = schedule.entry or []
if isinstance(entry_list, str):
try:
entry_list = json.loads(entry_list)
except json.JSONDecodeError:
entry_list = []
# Xóa trace ongoing cũ
entry_list = [e for e in entry_list if e.get('type') != 'PENALTY_RECALC']
entry_list.append({
"type": "PENALTY_RECALC",
"date": today.isoformat(),
"penalty_total": float(total_penalty),
"penalty_paid": float(penalty_paid),
"penalty_remain": float(schedule.penalty_remain),
"note": f"Tính lại tổng lãi đến {today}: {total_penalty:,.0f}"
})
schedule.entry = safe_json_serialize(entry_list)
schedule.save(update_fields=[
'penalty_amount', 'penalty_remain', 'remain_amount',
'batch_date', 'entry'
])
# ==========================================================================================
def allocate_payment_to_schedules(product_id):
"""
Phân bổ thanh toán cho một sản phẩm cụ thể.
Quét tất cả entry CR có allocation_remain > 0 của product này,
phân bổ tiếp vào các lịch chưa thanh toán (status=1).
"""
if not product_id:
return {"status": "no_product", "message": "Không có product_id"}
allocation_rule = get_allocation_rule()
updated_schedules = []
updated_entries = []
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)")
paid_payment_status = Payment_Status.objects.filter(id=2).first()
paid_txn_status = Transaction_Status.objects.filter(id=2).first()
with transaction.atomic():
try:
# Lấy product
product = Product.objects.get(id=product_id)
# Lấy transaction của product
booked = Product_Booked.objects.filter(product=product).first()
if not booked or not booked.transaction:
errors.append(f"Product {product_id}: Không tìm thấy Transaction")
return {
"status": "error",
"errors": errors
}
return {"status": "error", "errors": errors}
txn = booked.transaction
# Lấy transaction detail
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()
txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by('-create_time').first()
if not txn_detail:
errors.append(f"Product {product_id}: Không tìm thấy Transaction_Detail")
return {
"status": "error",
"errors": errors
}
return {"status": "error", "errors": errors}
# QUÉT TẤT CẢ ENTRY CR CÓ TIỀN THỪA (allocation_remain > 0) - KHÔNG PHẢI TÀI KHOẢN MIỄN LÃI
entries_with_remain = Internal_Entry.objects.select_for_update().filter(
product=product,
type__code='CR',
allocation_remain__gt=0
).exclude(
account__id=5 # Loại trừ tài khoản miễn lãi
).order_by('date', 'create_time')
).exclude(account__id=5).order_by('date', 'create_time')
if not entries_with_remain.exists():
return {
@@ -176,7 +301,6 @@ def allocate_payment_to_schedules(product_id):
"errors": []
}
# Lấy các lịch chưa thanh toán (status=1)
schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail=txn_detail,
status__id=1
@@ -191,14 +315,14 @@ def allocate_payment_to_schedules(product_id):
"errors": []
}
# TỔNG TIỀN PHÂN BỔ THÀNH CÔNG (PRINCIPAL + PENALTY)
total_principal_allocated = Decimal('0')
total_penalty_allocated = Decimal('0')
# PHÂN BỔ TỪNG ENTRY
for entry in entries_with_remain:
remaining = Decimal(str(entry.allocation_remain))
entry_date = entry.date
entry_date_str = entry_date if isinstance(entry_date, str) else entry_date.strftime("%Y-%m-%d")
remaining = Decimal(str(entry.allocation_remain))
if remaining <= 0:
continue
@@ -206,50 +330,32 @@ def allocate_payment_to_schedules(product_id):
entry_principal_allocated = Decimal('0')
entry_penalty_allocated = Decimal('0')
# Phân bổ vào các lịch
for sch in schedules:
if remaining <= 0:
break
# RULE: principal-fee (gốc trước, lãi sau)
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')
# Áp dụng quy tắc phân bổ
if allocation_rule == 'fee-principal':
# Phạt trước
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:
# Gốc trước
# Trả gốc trước
to_principal = min(remaining, amount_remain)
remaining -= to_principal
paid_amount += to_principal
amount_remain -= to_principal
# Trả lãi sau (nếu còn tiền)
to_penalty = min(remaining, penalty_remain)
remaining -= to_penalty
penalty_paid += to_penalty
penalty_remain -= to_penalty
allocated_here = to_penalty + to_principal
allocated_here = to_principal + to_penalty
if allocated_here <= 0:
continue
# Cập nhật entry tracking
entry_principal_allocated += to_principal
entry_penalty_allocated += to_penalty
@@ -258,41 +364,41 @@ def allocate_payment_to_schedules(product_id):
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)
sch.remain_amount = amount_remain + penalty_remain
# Lưu trace vào schedule
schedule_entry_list = sch.entry or []
if amount_remain <= 0:
sch.batch_date = datetime.strptime(entry_date_str, "%Y-%m-%d").date()
date_value = entry.date
if hasattr(date_value, 'isoformat'):
date_value = date_value.isoformat()
else:
date_value = str(date_value)
schedule_entry_list.append({
# Lưu trace PAYMENT
sch_entry_list = sch.entry or []
sch_entry_list.append({
"type": "PAYMENT",
"code": entry.code,
"date": entry_date_str,
"amount": float(allocated_here),
"date": date_value,
"type": "CR",
"principal": float(to_principal),
"penalty": float(to_penalty),
"rule": allocation_rule
"rule": "principal-fee"
})
sch.entry = schedule_entry_list
# Kiểm tra xem lịch đã thanh toán đủ chưa
if sch.amount_remain <= 0 and sch.penalty_remain <= 0 and paid_payment_status:
sch.status = paid_payment_status
sch.entry = safe_json_serialize(sch_entry_list)
# Lưu schedule (chưa tính lại lãi)
sch.save(update_fields=[
'paid_amount', 'penalty_paid', 'amount_remain',
'penalty_remain', 'remain_amount', 'entry', 'status'
'penalty_remain', 'remain_amount', 'entry', 'batch_date'
])
# ===== KEY: TÍNH LẠI LÃI SAU KHI PHÂN BỔ =====
update_penalty_after_allocation(sch)
# Cập nhật lại status nếu đã trả hết
if sch.amount_remain <= 0 and sch.penalty_remain <= 0 and paid_payment_status:
sch.status = paid_payment_status
sch.save(update_fields=['status'])
if sch.id not in updated_schedules:
updated_schedules.append(sch.id)
# Lưu chi tiết phân bổ vào entry
entry_allocation_detail.append({
"schedule_id": sch.id,
"schedule_code": sch.code,
@@ -302,65 +408,48 @@ def allocate_payment_to_schedules(product_id):
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
# Cập nhật entry allocation info
total_allocated_for_entry = entry_principal_allocated + entry_penalty_allocated
entry.allocation_amount = (entry.allocation_amount or Decimal('0')) + total_allocated_for_entry
entry.allocation_remain = remaining # Số tiền còn thừa
total_allocated = entry_principal_allocated + entry_penalty_allocated
entry.allocation_amount = (entry.allocation_amount or Decimal('0')) + total_allocated
entry.allocation_remain = remaining
entry.allocation_detail = entry_allocation_detail
entry.save(update_fields=['allocation_amount', 'allocation_remain', 'allocation_detail'])
if entry.id not in updated_entries:
updated_entries.append(entry.id)
# Cộng vào tổng
total_principal_allocated += entry_principal_allocated
total_penalty_allocated += entry_penalty_allocated
# Cập nhật Transaction_Detail - TÁCH RIÊNG PRINCIPAL VÀ PENALTY
# Cập nhật Transaction & Transaction_Detail
if total_principal_allocated > 0 or total_penalty_allocated > 0:
# Cập nhật amount_received (chỉ cộng principal)
txn_detail.amount_received = F('amount_received') + total_principal_allocated
txn_detail.amount_remaining = F('amount_remaining') - total_principal_allocated
# Cập nhật penalty_amount
if hasattr(txn_detail, 'penalty_amount'):
txn_detail.penalty_amount = F('penalty_amount') + total_penalty_allocated
txn_detail.save(update_fields=['amount_received', 'amount_remaining', 'penalty_amount'])
else:
txn_detail.save(update_fields=['amount_received', 'amount_remaining'])
txn_detail.refresh_from_db()
# Kiểm tra và cập nhật status nếu đã thanh toán đủ
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 - TÁCH RIÊNG PRINCIPAL VÀ PENALTY
if total_principal_allocated > 0 or total_penalty_allocated > 0:
# Cập nhật amount_received (chỉ cộng principal)
txn.amount_received = F('amount_received') + total_principal_allocated
txn.amount_remain = F('amount_remain') - total_principal_allocated
# Cập nhật penalty_amount
if hasattr(txn, 'penalty_amount'):
txn.penalty_amount = F('penalty_amount') + total_penalty_allocated
txn.save(update_fields=['amount_received', 'amount_remain', 'penalty_amount'])
else:
txn.save(update_fields=['amount_received', 'amount_remain'])
txn.refresh_from_db()
# Kiểm tra và cập nhật status nếu đã thanh toán đủ
if txn.amount_remain <= 0 and paid_txn_status:
txn.status = paid_txn_status
txn.save(update_fields=['status'])
except Product.DoesNotExist:
errors.append(f"Product {product_id}: Không tồn tại")
except Exception as exc:
errors.append(f"Product {product_id}: Lỗi phân bổ - {str(exc)}")
errors.append(str(exc))
import traceback
print(traceback.format_exc())
@@ -369,13 +458,12 @@ def allocate_payment_to_schedules(product_id):
"updated_schedules": updated_schedules,
"updated_entries": updated_entries,
"errors": errors,
"rule_used": allocation_rule,
"rule_used": "principal-fee",
"total_principal_allocated": float(total_principal_allocated),
"total_penalty_allocated": float(total_penalty_allocated)
}
# ==========================================================================================
# HÀM MIỄN LÃI - XỬ LÝ ENTRY TỪ TÀI KHOẢN MIỄN LÃI (ID=5)
# ==========================================================================================
def allocate_penalty_reduction(product_id):
"""
@@ -391,7 +479,6 @@ def allocate_penalty_reduction(product_id):
with transaction.atomic():
try:
# Lấy product
product = Product.objects.get(id=product_id)
booked = Product_Booked.objects.filter(product=product).first()
@@ -420,7 +507,6 @@ def allocate_penalty_reduction(product_id):
"errors": errors
}
# Lấy các entry CR từ tài khoản miễn lãi (id=5) có tiền thừa
reduction_entries = Internal_Entry.objects.select_for_update().filter(
product=product,
type__code='CR',
@@ -437,7 +523,6 @@ def allocate_penalty_reduction(product_id):
"errors": []
}
# Lấy các lịch chưa thanh toán (status=1)
schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail=txn_detail,
status=1
@@ -452,7 +537,6 @@ def allocate_penalty_reduction(product_id):
"errors": []
}
# Xử lý từng entry miễn lãi
for entry in reduction_entries:
remaining_reduce = Decimal(str(entry.allocation_remain))
@@ -470,7 +554,6 @@ def allocate_penalty_reduction(product_id):
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)
if to_reduce <= 0:
@@ -479,34 +562,19 @@ def allocate_penalty_reduction(product_id):
remaining_reduce -= to_reduce
entry_reduction_allocated += 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
schedule.remain_amount = current_remain_amount - 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 vào schedule
schedule_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)
schedule_entry_list.append({
"code": entry.code,
"amount": float(to_reduce),
"date": date_value,
sch_entry_list = schedule.entry or []
sch_entry_list.append({
"type": "REDUCTION",
"note": "Miễn lãi phạt quá hạn"
"code": entry.code,
"date": datetime.now().strftime("%Y-%m-%d"),
"amount": float(to_reduce)
})
schedule.entry = schedule_entry_list
schedule.entry = safe_json_serialize(sch_entry_list)
# Lưu lại schedule
schedule.save(update_fields=[
'penalty_reduce', 'penalty_remain', 'remain_amount', 'entry'
])
@@ -514,7 +582,6 @@ def allocate_penalty_reduction(product_id):
if schedule.id not in updated_schedules:
updated_schedules.append(schedule.id)
# Lưu chi tiết vào entry
entry_allocation_detail.append({
"schedule_id": schedule.id,
"schedule_code": schedule.code,
@@ -523,20 +590,16 @@ def allocate_penalty_reduction(product_id):
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
# Cập nhật entry allocation info
entry.allocation_amount = (entry.allocation_amount or Decimal('0')) + entry_reduction_allocated
entry.allocation_remain = remaining_reduce
entry.allocation_detail = entry_allocation_detail
entry.save(update_fields=['allocation_amount', 'allocation_remain', 'allocation_detail'])
if entry.id not in updated_entries:
updated_entries.append(entry.id)
except Product.DoesNotExist:
errors.append(f"Product {product_id}: Không tồn tại")
except Exception as exc:
errors.append(f"Product {product_id}: Lỗi miễn lãi - {str(exc)}")
errors.append(str(exc))
import traceback
print(traceback.format_exc())
@@ -544,25 +607,17 @@ def allocate_penalty_reduction(product_id):
"status": "success" if not errors else "partial_failure",
"updated_schedules": updated_schedules,
"updated_entries": updated_entries,
"errors": errors,
"message": f"Đã miễn lãi cho {len(updated_schedules)} lịch thanh toán"
"errors": errors
}
# ==========================================================================================
# BACKGROUND FUNCTION - NHẬN PRODUCT_ID
# ==========================================================================================
def background_allocate(product_id):
"""
Chạy phân bổ ngầm cho một product_id cụ thể.
Quét tất cả entry cũ + mới có tiền thừa để phân bổ.
"""
"""Background task để chạy allocation sau khi tạo entry"""
try:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation started for product_id={product_id}")
# Phân bổ thanh toán thông thường
normal_result = allocate_payment_to_schedules(product_id)
# Phân bổ miễn lãi
reduction_result = allocate_penalty_reduction(product_id)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation completed for product_id={product_id}:")
@@ -574,12 +629,13 @@ def background_allocate(product_id):
import traceback
print(traceback.format_exc())
# ==========================================================================================
# API TẠO MỘT BÚT TOÁN
# API VIEWS
# ==========================================================================================
@api_view(['POST'])
def account_entry(request):
print(request.data.get('date'))
"""View function để tạo bút toán (được gọi từ urls.py)"""
ref = request.data.get('ref')
response_data, created_entry = account_entry_api(
@@ -598,37 +654,28 @@ def account_entry(request):
if 'error' in response_data:
return Response(response_data, status=400)
# Lưu product_id để chạy sau khi response
product_id_to_allocate = created_entry.product_id if created_entry else None
product_id = created_entry.product_id if created_entry else None
# Tạo response trước
response = 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..."
})
# Chạy background allocation SAU KHI transaction đã commit
if product_id_to_allocate:
if product_id:
def run_allocation():
thread = threading.Thread(
target=background_allocate,
args=(product_id_to_allocate,),
daemon=True
)
thread = threading.Thread(target=background_allocate, args=(product_id,), daemon=True)
thread.start()
transaction.on_commit(run_allocation)
return response
# ==========================================================================================
# API TẠO NHIỀU BÚT TOÁN
# ==========================================================================================
@api_view(['POST'])
def account_multi_entry(request):
"""Tạo nhiều bút toán cùng lúc"""
try:
result = []
product_ids = set() # Thu thập các product_id cần phân bổ
product_ids = set()
data_list = request.data.get('data', [])
with transaction.atomic():
@@ -651,13 +698,11 @@ def account_multi_entry(request):
if created_entry and created_entry.product_id:
product_ids.add(created_entry.product_id)
# Tạo response
response = Response({
"entries": result,
"message": f"Bút toán đã tạo thành công. Phân bổ thanh toán đang chạy ngầm cho {len(product_ids)} sản phẩm..."
})
# Chạy background allocation SAU KHI transaction đã commit
if product_ids:
def run_allocations():
for product_id in product_ids:
@@ -675,3 +720,195 @@ def account_multi_entry(request):
except Exception as e:
print({'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"})
return Response({'error': str(e)}, status=400)
@api_view(['POST'])
def delete_entry(request):
"""View function để xóa bút toán (tương thích với urls.py)"""
entry_id = request.data.get('id')
try:
with transaction.atomic():
try:
entry = Internal_Entry.objects.select_for_update().get(id=entry_id)
except Internal_Entry.DoesNotExist:
return Response({
'error': f'Bút toán với ID {entry_id} không tồn tại'
}, status=404)
entry_info = {
'id': entry.id,
'code': entry.code,
'amount': float(entry.amount),
'type': entry.type.code,
'account_code': entry.account.code,
'account_id': entry.account_id,
'product_id': entry.product_id if entry.product else None,
'allocation_amount': float(entry.allocation_amount or 0),
'allocation_remain': float(entry.allocation_remain or 0)
}
allocation_detail = entry.allocation_detail or []
schedules_reversed = []
total_principal_reversed = Decimal('0')
total_penalty_reversed = Decimal('0')
total_reduction_reversed = Decimal('0')
for allocation in allocation_detail:
schedule_id = allocation.get('schedule_id')
allocated_amount = Decimal(str(allocation.get('amount', 0)))
principal = Decimal(str(allocation.get('principal', 0)))
penalty = Decimal(str(allocation.get('penalty', 0)))
allocation_type = allocation.get('type', 'PAYMENT')
if not schedule_id or allocated_amount <= 0:
continue
try:
schedule = Payment_Schedule.objects.select_for_update().get(id=schedule_id)
if allocation_type == 'REDUCTION':
schedule.penalty_reduce = (schedule.penalty_reduce or Decimal('0')) - allocated_amount
schedule.penalty_remain = (schedule.penalty_remain or Decimal('0')) + allocated_amount
schedule.remain_amount = (schedule.remain_amount or Decimal('0')) + allocated_amount
total_reduction_reversed += allocated_amount
schedule.save(update_fields=['penalty_reduce', 'penalty_remain', 'remain_amount'])
else:
schedule.paid_amount = (schedule.paid_amount or Decimal('0')) - principal
schedule.penalty_paid = (schedule.penalty_paid or Decimal('0')) - penalty
schedule.amount_remain = (schedule.amount_remain or Decimal('0')) + principal
schedule.penalty_remain = (schedule.penalty_remain or Decimal('0')) + penalty
schedule.remain_amount = (schedule.remain_amount or Decimal('0')) + allocated_amount
total_principal_reversed += principal
total_penalty_reversed += penalty
if schedule.amount_remain > 0 or schedule.penalty_remain > 0:
try:
unpaid_status = Payment_Status.objects.get(id=1)
schedule.status = unpaid_status
except Payment_Status.DoesNotExist:
pass
schedule.save(update_fields=[
'paid_amount', 'penalty_paid', 'amount_remain',
'penalty_remain', 'remain_amount', 'status'
])
# Xóa entry trace của bút toán này
schedule_entries = schedule.entry or []
schedule_entries = [e for e in schedule_entries if e.get('code') != entry.code]
schedule.entry = safe_json_serialize(schedule_entries)
schedule.save(update_fields=['entry'])
# Tính lại lãi sau khi hoàn tác
update_penalty_after_allocation(schedule)
schedules_reversed.append({
'schedule_id': schedule.id,
'schedule_code': schedule.code,
'amount_reversed': float(allocated_amount),
'principal_reversed': float(principal),
'penalty_reversed': float(penalty),
'type': allocation_type
})
except Payment_Schedule.DoesNotExist:
continue
txn_detail_updated = False
txn_updated = False
if entry.product:
try:
booked = Product_Booked.objects.filter(product=entry.product).first()
if booked and booked.transaction:
txn = booked.transaction
try:
current = Transaction_Current.objects.get(transaction=txn)
txn_detail = current.detail
except Transaction_Current.DoesNotExist:
txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by('-create_time').first()
if txn_detail:
if entry.account_id != 5:
fields_to_update = []
if total_principal_reversed > 0:
txn_detail.amount_received = F('amount_received') - total_principal_reversed
txn_detail.amount_remaining = F('amount_remaining') + total_principal_reversed
fields_to_update.extend(['amount_received', 'amount_remaining'])
if total_penalty_reversed > 0 and hasattr(txn_detail, 'penalty_amount'):
txn_detail.penalty_amount = F('penalty_amount') - total_penalty_reversed
fields_to_update.append('penalty_amount')
if fields_to_update:
txn_detail.save(update_fields=fields_to_update)
txn_detail.refresh_from_db()
txn_detail_updated = True
if txn_detail.amount_remaining > 0:
try:
unpaid_status = Transaction_Status.objects.get(id=1)
txn_detail.status = unpaid_status
txn_detail.save(update_fields=['status'])
except:
pass
if entry.account_id != 5:
fields_to_update = []
if total_principal_reversed > 0:
txn.amount_received = F('amount_received') - total_principal_reversed
txn.amount_remain = F('amount_remain') + total_principal_reversed
fields_to_update.extend(['amount_received', 'amount_remain'])
if total_penalty_reversed > 0 and hasattr(txn, 'penalty_amount'):
txn.penalty_amount = F('penalty_amount') - total_penalty_reversed
fields_to_update.append('penalty_amount')
if fields_to_update:
txn.save(update_fields=fields_to_update)
txn.refresh_from_db()
txn_updated = True
if txn.amount_remain > 0:
try:
unpaid_status = Transaction_Status.objects.get(id=1)
txn.status = unpaid_status
txn.save(update_fields=['status'])
except:
pass
except Exception as e:
print(f"Lỗi khi hoàn tác Transaction: {str(e)}")
account = Internal_Account.objects.select_for_update().get(id=entry.account_id)
entry_amount = float(entry.amount)
if entry.type.code == 'CR':
account.balance = (account.balance or 0) - entry_amount
else:
account.balance = (account.balance or 0) + entry_amount
account.save(update_fields=['balance'])
entry.delete()
return Response({
'success': True,
'message': 'Đã xóa bút toán và hoàn tác tất cả phân bổ thành công',
'entry': entry_info,
'reversed': {
'schedules_count': len(schedules_reversed),
'schedules': schedules_reversed,
'total_principal_reversed': float(total_principal_reversed),
'total_penalty_reversed': float(total_penalty_reversed),
'total_reduction_reversed': float(total_reduction_reversed),
'transaction_detail_updated': txn_detail_updated,
'transaction_updated': txn_updated
},
'account_balance_restored': True
})
except Exception as e:
import traceback
print(traceback.format_exc())
return Response({
'error': f'Đã xảy ra lỗi khi xóa bút toán: {str(e)}'
}, status=500)

View File

@@ -156,7 +156,7 @@ def start():
Khởi động APScheduler và thêm tác vụ quét job.
"""
scheduler = BackgroundScheduler(timezone='Asia/Ho_Chi_Minh')
# Chạy tác vụ quét job mỗi 60 giây
scheduler.add_job(scan_and_run_due_jobs, 'interval', seconds=60, id='scan_due_jobs_job', replace_existing=True)
# Chạy tác vụ quét job mỗi 5 giây
scheduler.add_job(scan_and_run_due_jobs, 'interval', seconds=5, id='scan_due_jobs_job', replace_existing=True)
scheduler.start()
#logger.info("APScheduler started... Jobs will be scanned every 60 seconds.")
#logger.info("APScheduler started... Jobs will be scanned every 5 seconds.")

View File

@@ -288,3 +288,88 @@ def lookup_data_action(params, context):
except Exception as e:
print(f" [LOOKUP] EXCEPTION: {str(e)}")
raise e
# ============================
# Quét và phân bổ toàn bộ bút toán còn phần dư
# ============================
@register_action("ALLOCATE_ALL_PENDING", schema={})
def allocate_all_pending_action(params, context):
"""
Quét toàn bộ Internal_Entry có allocation_remain > 0 (type CR),
group by product_id, gọi phân bổ cho từng product cho đến khi hết.
"""
from app.payment import allocate_payment_to_schedules, allocate_penalty_reduction
from decimal import Decimal
Internal_Entry = apps.get_model("app", "Internal_Entry")
Payment_Schedule = apps.get_model("app", "Payment_Schedule")
Product_Booked = apps.get_model("app", "Product_Booked")
Transaction_Current = apps.get_model("app", "Transaction_Current")
Transaction_Detail = apps.get_model("app", "Transaction_Detail")
# ---------- Lấy toàn bộ product_id còn entry chưa phân bổ hết ----------
product_ids = list(
Internal_Entry.objects.filter(
type__code="CR",
allocation_remain__gt=0,
product__isnull=False
)
.values_list("product_id", flat=True)
.distinct()
)
print(f" [ALLOCATE_ALL] Tìm được {len(product_ids)} product có entry còn phần dư")
if not product_ids:
return {"total_products": 0, "results": []}
# ---------- DEBUG: dump trạng thái trước khi phân bổ ----------
for pid in product_ids:
print(f"\n [DEBUG] ===== Product {pid} — trạng thái TRƯỚC phân bổ =====")
# Entries
entries = Internal_Entry.objects.filter(
product_id=pid, type__code="CR", allocation_remain__gt=0
).order_by("date", "create_time")
for e in entries:
print(f" Entry id={e.id} | account_id={e.account_id} | amount={e.amount} | allocation_remain={e.allocation_remain} | date={e.date}")
# Lấy txn_detail của product
booked = Product_Booked.objects.filter(product_id=pid).first()
if not booked or not booked.transaction:
print(f" !! Không có Product_Booked / Transaction")
continue
txn = booked.transaction
txn_detail = None
try:
current = Transaction_Current.objects.get(transaction=txn)
txn_detail = current.detail
except Exception:
txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by("-create_time").first()
if not txn_detail:
print(f" !! Không có Transaction_Detail")
continue
# Schedules
all_schedules = Payment_Schedule.objects.filter(txn_detail=txn_detail).order_by("cycle", "from_date")
unpaid = all_schedules.filter(status__id=1)
print(f" Tổng schedule: {all_schedules.count()} | Chưa thanh toán (status=1): {unpaid.count()}")
for s in all_schedules:
print(f" Schedule id={s.id} | cycle={s.cycle} | status_id={s.status_id} | amount_remain={s.amount_remain} | penalty_remain={s.penalty_remain} | remain_amount={s.remain_amount}")
# ---------- Chạy phân bổ ----------
results = []
for product_id in product_ids:
try:
normal = allocate_payment_to_schedules(product_id)
reduction = allocate_penalty_reduction(product_id)
results.append({"product_id": product_id, "normal": normal, "reduction": reduction})
print(f" [ALLOCATE_ALL] Product {product_id}: OK — normal={normal}")
except Exception as e:
print(f" [ALLOCATE_ALL] Product {product_id}: ERROR - {str(e)}")
results.append({"product_id": product_id, "error": str(e)})
return {"total_products": len(product_ids), "results": results}