From 5b360753d86104149443be2164b22f0308911373 Mon Sep 17 00:00:00 2001 From: anhduy-tech Date: Wed, 4 Feb 2026 21:01:54 +0700 Subject: [PATCH] changes --- api/__pycache__/settings.cpython-313.pyc | Bin 3451 -> 3451 bytes api/__pycache__/urls.cpython-313.pyc | Bin 4575 -> 4669 bytes api/urls.py | 3 +- app/__pycache__/models.cpython-313.pyc | Bin 140595 -> 140720 bytes app/__pycache__/payment.cpython-313.pyc | Bin 25637 -> 39041 bytes .../workflow_actions.cpython-313.pyc | Bin 12078 -> 16857 bytes ...schedule_link_payment_schedule_ref_code.py | 23 + app/models.py | 2 + app/payment.py | 637 ++++++++++++------ app/scheduler.py | 6 +- app/workflow_actions.py | 85 +++ 11 files changed, 552 insertions(+), 204 deletions(-) create mode 100644 app/migrations/0374_payment_schedule_link_payment_schedule_ref_code.py diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index aa5f5b526f4ca93cc16feedb9c2b1945fbd681d6..3057f227b31cae007276b8873f5084dd4d79d004 100644 GIT binary patch delta 20 acmew@^;?SjGcPX}0}vGXH*e%F-~|9h8wJV$ delta 20 acmew@^;?SjGcPX}0}yN|soBU~zzYCJhz2GA diff --git a/api/__pycache__/urls.cpython-313.pyc b/api/__pycache__/urls.cpython-313.pyc index 07f6f1f5de1a7133d16292317475606495d216b4..1a35e3e0619ea70b5e1b2002f94ce05136668af0 100644 GIT binary patch delta 877 zcmZY8%}*0S7zXg&GD{TtDHJR%3;kHSZE5*vmv##+C6UNSsnJC60C=GxB*qwo80A2? zaWbtnVS8#lSx-thz(Etvo}5g~+~ndPFvNIw-@!x^HrZtJdv>1LeY1NJ{1USLve`NW z^Ej>UZ>?2r9`VO<_*AM^BuQOuf3Q(K_qA^)HzSeg$xrF4)b+}~QGQb1DaXrCceY=S zgi4Xr$#Lg>i_LoFkGa=G`y(}LwuqUZ=SSPrZ`_x&P=9h7PnV=PB z1GjVcH?{ZE@647YbAURzYM*v#dJd=O2%2Xu@LQ%IE*kFrhFq5kQdt*hH|Tt2_h_%W zM^KQt0eg6b+EQqA5=SQqnqs}+x39Ygybnqi45?wQTM1H_7tjY-G?Yf7o**c~`T=D@ zs=akMSK)e)w?$eSjpcAGM^K*m!2{ffTGB_y2M0kJ76c7lzgqzxf^F_r9v-b#*Wd-h z>KkV_pK%nD|7IM6+lP6L(%hwC9fx&-vMdgs;BIb8)eKfM1nF!9e3W~?@q!Md@IZ=a)#&~#IO8vD~QjGS5%UA=t51@Uj(^Dmwe)mqXO;Q5`a+}~7a zK*s_8va@FX%;n+FItRHqp)B=|V(%zHV{Bf8XIkJv(TLK%IPQxRlwgbIy3a082;$OJ Yug}<|@;H{q;nTYZ3lBQCGv;6L2RU*PfdBvi delta 685 zcmZXSy>AmS7>9eU6G3wMDx@JHJo4Rdrz~R7iAU zV@&YS%R;h|5!8V$`~wVCEhAY>jI2iNsP|qvRKk*%=Xw3}tJnTE|0^5%8wv#`+_nGX z?SbA4C8e`3_^Z!%iKA9w~a63?TYbAHTdQCosXY)8R`4a^% zNl>z@F67m-Jzm|V)m?_#B7{jM z5`O_U>RJ2q5if1f(gs7DA_jaK0=J#`KEaf^+WUyqbR&u9U{i2p%kd55zRZv&?gC5$eB@{Yt8X!6h*^LNz#XT_(|MZCGgJ`w z0B5{-V`TH(8qKXSR26fWNYVH+u=D?04fq0tt#_ZEbb1}=6a&-&UhBmGXac-8xf~`h zqvOe0UiiYu$Mt2ZFEdmY_c8f+Wfwi09k#hvr&^t%hFHSn{K^(Rn|2PkVpGLts3=O9 z{ENryTW-=X 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 bf1fdc7d6d50e66396792c9c1e746960cf7ac89c..002b9b24886fc73587d5431ea8c8f6ed8b279850 100644 GIT binary patch delta 1908 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 delta 10636 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 diff --git a/app/__pycache__/workflow_actions.cpython-313.pyc b/app/__pycache__/workflow_actions.cpython-313.pyc index 45d87c91522b0d15b499a6ebf75dd3fb58cc3ac2..8a8ce0fc024ba5939497c964ad5449e4428e5926 100644 GIT binary patch delta 4416 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 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