changes
This commit is contained in:
Binary file not shown.
371
app/payment.py
371
app/payment.py
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user