This commit is contained in:
anhduy-tech
2026-02-05 18:15:03 +07:00
parent 5b360753d8
commit d6eec950e9
32 changed files with 117 additions and 258 deletions

View File

@@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'django-insecure-_u202k$8qq2p*cr_eo(7k!0ngr5^n)27@85+5oy8&41(u6&j54'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = False
ALLOWED_HOSTS = ['*']
@@ -81,7 +81,7 @@ ASGI_APPLICATION = 'api.asgi.application'
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
#prod:5.223.52.193 dev:5.223.42.146
MODE = 'dev'
MODE = 'prod'
DBHOST = '172.17.0.1' if MODE == 'prod' else '5.223.42.146'
DATABASES = {

View File

@@ -12,16 +12,6 @@ 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')"""
@@ -130,162 +120,42 @@ def get_allocation_rule():
return 'principal-fee'
# ==========================================================================================
# 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
DAILY_PENALTY_RATE = Decimal('0.0005') # 0.05% mỗi ngày
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 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):
if not product_id:
return {"status": "no_product", "message": "Không có product_id"}
updated_schedules = []
updated_entries = []
errors = []
paid_payment_status = Payment_Status.objects.filter(id=2).first()
paid_txn_status = Transaction_Status.objects.filter(id=2).first()
today = datetime.now().date()
DAILY_PENALTY_RATE = Decimal('0.0005') # Giả định 0.05%
with transaction.atomic():
try:
product = Product.objects.get(id=product_id)
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": ["Không tìm thấy Transaction"]}
txn = booked.transaction
txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by('-create_time').first()
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"Product {product_id}: Không tìm thấy Transaction_Detail")
return {"status": "error", "errors": errors}
# Lấy các bút toán CR còn dư tiền (Sắp xếp chính xác để trừ theo thứ tự thời gian)
entries_with_remain = Internal_Entry.objects.select_for_update().filter(
product=product,
type__code='CR',
@@ -293,177 +163,169 @@ def allocate_payment_to_schedules(product_id):
).exclude(account__id=5).order_by('date', 'create_time')
if not entries_with_remain.exists():
return {
"status": "success",
"message": "Không có entry nào cần phân bổ",
"updated_schedules": [],
"updated_entries": [],
"errors": []
}
return {"status": "success", "message": "Không có tiền để phân bổ"}
# Lấy lịch nợ (chỉ lấy status=1)
schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail=txn_detail,
status__id=1
).order_by('cycle', 'from_date')
if not schedules.exists():
return {
"status": "success",
"message": "Không có lịch thanh toán cần phân bổ",
"updated_schedules": [],
"updated_entries": [],
"errors": []
}
total_principal_allocated = Decimal('0')
total_penalty_allocated = Decimal('0')
for entry in entries_with_remain:
entry_date = entry.date
entry_date_str = entry_date if isinstance(entry_date, str) else entry_date.strftime("%Y-%m-%d")
if isinstance(entry_date, str):
entry_date = datetime.strptime(entry_date, "%Y-%m-%d").date()
remaining = Decimal(str(entry.allocation_remain))
if remaining <= 0:
continue
if remaining <= 0: continue
entry_allocation_detail = entry.allocation_detail or []
entry_principal_allocated = Decimal('0')
entry_penalty_allocated = Decimal('0')
for sch in schedules:
if remaining <= 0:
break
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))
current_amount_remain = Decimal(str(sch.amount_remain or 0))
# --- BƯỚC 1: LẤY LÃI TÍCH LŨY TỪ TRACE ---
last_entry_date = None
accumulated_penalty_to_last = Decimal('0')
if sch.entry:
for e in sch.entry:
if e.get('type') == 'PAYMENT':
e_date = datetime.strptime(e['date'], "%Y-%m-%d").date()
# Dùng <= để lấy được cả bút toán cùng ngày nộp trước đó
if e_date <= entry_date:
if e.get('penalty_to_this_entry') is not None:
# Luôn cập nhật để lấy con số lũy kế MỚI NHẤT
accumulated_penalty_to_last = Decimal(str(e['penalty_to_this_entry']))
if not last_entry_date or e_date > last_entry_date:
last_entry_date = e_date
# Trả gốc trước
to_principal = min(remaining, amount_remain)
# --- BƯỚC 2: TÍNH LÃI PHÁT SINH MỚI ---
penalty_added_to_entry = Decimal('0')
days_for_trace = 0
if current_amount_remain > 0:
if last_entry_date:
days_between = max(0, (entry_date - last_entry_date).days)
penalty_added_to_entry = current_amount_remain * Decimal(days_between) * DAILY_PENALTY_RATE
days_for_trace = days_between
else:
days_overdue_to_entry = max(0, (entry_date - sch.to_date).days)
penalty_added_to_entry = current_amount_remain * Decimal(days_overdue_to_entry) * DAILY_PENALTY_RATE
days_for_trace = days_overdue_to_entry
# Tổng nợ lãi tại thời điểm này
penalty_to_this_entry = accumulated_penalty_to_last + penalty_added_to_entry
# QUAN TRỌNG: Nợ lãi thực tế cần trả ngay bây giờ
penalty_to_pay_now = max(Decimal('0'), penalty_to_this_entry - Decimal(str(sch.penalty_paid or 0)))
# --- BƯỚC 3: PHÂN BỔ TIỀN ---
to_principal = min(remaining, current_amount_remain)
remaining -= to_principal
paid_amount += to_principal
amount_remain -= to_principal
amount_remain_after = current_amount_remain - to_principal
# Trả lãi sau (nếu còn tiền)
to_penalty = min(remaining, penalty_remain)
to_penalty = min(remaining, penalty_to_pay_now)
remaining -= to_penalty
penalty_paid += to_penalty
penalty_remain -= to_penalty
allocated_here = to_principal + to_penalty
# Nếu vẫn không có gì để phân bổ cho kỳ này, bỏ qua sang kỳ sau
if allocated_here <= 0:
continue
entry_principal_allocated += to_principal
entry_penalty_allocated += to_penalty
# Cập nhật schedule
sch.paid_amount = paid_amount
sch.penalty_paid = penalty_paid
sch.amount_remain = amount_remain
sch.penalty_remain = penalty_remain
sch.remain_amount = amount_remain + penalty_remain
# --- BƯỚC 4: LÃI DỰ PHÒNG ĐẾN NAY ---
days_from_entry_to_today = max(0, (today - entry_date).days)
additional_penalty_to_today = Decimal('0')
if amount_remain_after > 0:
additional_penalty_to_today = amount_remain_after * Decimal(days_from_entry_to_today) * DAILY_PENALTY_RATE
if amount_remain <= 0:
sch.batch_date = datetime.strptime(entry_date_str, "%Y-%m-%d").date()
# --- CẬP NHẬT DỮ LIỆU ---
sch.paid_amount = Decimal(str(sch.paid_amount or 0)) + to_principal
sch.penalty_paid = Decimal(str(sch.penalty_paid or 0)) + to_penalty
sch.amount_remain = amount_remain_after
sch.penalty_amount = penalty_to_this_entry + additional_penalty_to_today
sch.penalty_remain = max(Decimal('0'), sch.penalty_amount - sch.penalty_paid)
sch.remain_amount = sch.amount_remain + sch.penalty_remain
sch.ovd_days = days_for_trace + days_from_entry_to_today
# Lưu trace PAYMENT
# Ghi Trace
sch_entry_list = sch.entry or []
sch_entry_list.append({
"type": "PAYMENT",
"code": entry.code,
"date": entry_date_str,
"date": entry_date.strftime("%Y-%m-%d"),
"amount": float(allocated_here),
"principal": float(to_principal),
"penalty": float(to_penalty),
"rule": "principal-fee"
"penalty_added_to_entry": float(penalty_added_to_entry),
"penalty_to_this_entry": float(penalty_to_this_entry),
"amount_remain_after_allocation": float(amount_remain_after),
})
sch.entry = safe_json_serialize(sch_entry_list)
sch.entry = sch_entry_list # Lưu lại 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', '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:
if sch.amount_remain <= 0 and sch.penalty_remain <= 0:
sch.status = paid_payment_status
sch.save(update_fields=['status'])
if sch.id not in updated_schedules:
updated_schedules.append(sch.id)
sch.save()
if sch.id not in updated_schedules: updated_schedules.append(sch.id)
entry_allocation_detail.append({
"schedule_id": sch.id,
"schedule_code": sch.code,
"amount": float(allocated_here),
"principal": float(to_principal),
"penalty": float(to_penalty),
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
"schedule_id": sch.id, "amount": float(allocated_here),
"principal": float(to_principal), "penalty": float(to_penalty)
})
total_allocated = entry_principal_allocated + entry_penalty_allocated
entry.allocation_amount = (entry.allocation_amount or Decimal('0')) + total_allocated
# Cập nhật Entry nguồn
entry.allocation_amount = (entry.allocation_amount or Decimal('0')) + (entry_principal_allocated + entry_penalty_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)
entry.save()
total_principal_allocated += entry_principal_allocated
total_penalty_allocated += entry_penalty_allocated
# Cập nhật Transaction & Transaction_Detail
## Cập nhật Transaction (Giữ nguyên gốc)
if total_principal_allocated > 0 or total_penalty_allocated > 0:
# Cập nhật tiền gốc đã nhận
txn_detail.amount_received = F('amount_received') + total_principal_allocated
txn_detail.amount_remaining = F('amount_remaining') - total_principal_allocated
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()
if txn_detail.amount_remaining <= 0 and paid_txn_status:
txn_detail.status = paid_txn_status
txn_detail.save(update_fields=['status'])
txn_detail.save()
txn.amount_received = F('amount_received') + total_principal_allocated
txn.amount_remain = F('amount_remain') - total_principal_allocated
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.save()
# QUAN TRỌNG: Kiểm tra để đóng Status
txn_detail.refresh_from_db()
txn.refresh_from_db()
if txn.amount_remain <= 0 and paid_txn_status:
txn.status = paid_txn_status
txn.save(update_fields=['status'])
# Nếu gốc đã hết (amount_remaining <= 0)
# VÀ không còn kỳ lịch thanh toán nào chưa hoàn thành (đã sạch nợ lãi)
has_pending_sch = Payment_Schedule.objects.filter(txn_detail=txn_detail, status__id=1).exists()
if txn_detail.amount_remaining <= 0 and not has_pending_sch:
if paid_txn_status:
txn_detail.status = paid_txn_status
txn_detail.save()
txn.status = paid_txn_status
txn.save()
return {"status": "success", "updated_schedules": updated_schedules}
except Exception as exc:
errors.append(str(exc))
import traceback
print(traceback.format_exc())
return {
"status": "success" if not errors else "partial_failure",
"updated_schedules": updated_schedules,
"updated_entries": updated_entries,
"errors": errors,
"rule_used": "principal-fee",
"total_principal_allocated": float(total_principal_allocated),
"total_penalty_allocated": float(total_penalty_allocated)
}
return {"status": "error", "errors": [str(exc)]}
# ==========================================================================================
def allocate_penalty_reduction(product_id):
"""
@@ -800,9 +662,6 @@ def delete_entry(request):
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,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.