diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index aa5f5b52..3057f227 100644 Binary files a/api/__pycache__/settings.cpython-313.pyc and b/api/__pycache__/settings.cpython-313.pyc differ diff --git a/api/__pycache__/urls.cpython-313.pyc b/api/__pycache__/urls.cpython-313.pyc index 07f6f1f5..1a35e3e0 100644 Binary files a/api/__pycache__/urls.cpython-313.pyc and b/api/__pycache__/urls.cpython-313.pyc differ diff --git a/api/urls.py b/api/urls.py index 7f505280..eefdb841 100644 --- a/api/urls.py +++ b/api/urls.py @@ -54,5 +54,6 @@ urlpatterns = [ re_path('model-fields/(?P.+)/', 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) ] \ No newline at end of file diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index bf1fdc7d..002b9b24 100644 Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/__pycache__/payment.cpython-313.pyc b/app/__pycache__/payment.cpython-313.pyc index 16e5de7b..2c6da666 100644 Binary files a/app/__pycache__/payment.cpython-313.pyc and b/app/__pycache__/payment.cpython-313.pyc differ diff --git a/app/__pycache__/workflow_actions.cpython-313.pyc b/app/__pycache__/workflow_actions.cpython-313.pyc index 45d87c91..8a8ce0fc 100644 Binary files a/app/__pycache__/workflow_actions.cpython-313.pyc and b/app/__pycache__/workflow_actions.cpython-313.pyc differ diff --git a/app/migrations/0374_payment_schedule_link_payment_schedule_ref_code.py b/app/migrations/0374_payment_schedule_link_payment_schedule_ref_code.py new file mode 100644 index 00000000..40b0ee1b --- /dev/null +++ b/app/migrations/0374_payment_schedule_link_payment_schedule_ref_code.py @@ -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), + ), + ] diff --git a/app/models.py b/app/models.py index b9a9348f..348bd6b2 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/payment.py b/app/payment.py index 99e298be..c8e05e59 100644 --- a/app/payment.py +++ b/app/payment.py @@ -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 LẠI TOÀN BỘ ENTRY CŨ CÓ TIỀN THỪA +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 + + +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: + 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') + # Trả gốc trước + to_principal = min(remaining, amount_remain) + remaining -= to_principal + paid_amount += to_principal + amount_remain -= to_principal - # Á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 - 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 + # 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: @@ -674,4 +719,196 @@ 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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/scheduler.py b/app/scheduler.py index a68462e8..a81142c6 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -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.") diff --git a/app/workflow_actions.py b/app/workflow_actions.py index ff82965e..8f521607 100644 --- a/app/workflow_actions.py +++ b/app/workflow_actions.py @@ -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} \ No newline at end of file