diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index a3bf485e..bfe18a00 100644 Binary files a/api/__pycache__/settings.cpython-313.pyc and b/api/__pycache__/settings.cpython-313.pyc differ diff --git a/api/settings.py b/api/settings.py index 3be27995..32b12399 100644 --- a/api/settings.py +++ b/api/settings.py @@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = 'django-insecure-_u202k$8qq2p*cr_eo(7k!0ngr5^n)27@85+5oy8&41(u6&j54' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False ALLOWED_HOSTS = ['*'] @@ -81,8 +81,8 @@ ASGI_APPLICATION = 'api.asgi.application' # https://docs.djangoproject.com/en/4.1/ref/settings/#databases #prod:5.223.52.193 dev:5.223.42.146 -MODE = 'dev' -DBHOST = '172.17.0.1' if MODE == 'prod' else '5.223.42.146' +MODE = 'prod' +DBHOST = '172.17.0.1' if MODE == 'prod' else '5.223.52.193' DATABASES = { 'default': { diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index 002b9b24..7f8d4936 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 242703f7..294e8a22 100644 Binary files a/app/__pycache__/payment.cpython-313.pyc and b/app/__pycache__/payment.cpython-313.pyc differ diff --git a/app/migrations/0375_alter_internal_entry_ref.py b/app/migrations/0375_alter_internal_entry_ref.py new file mode 100644 index 00000000..84878de2 --- /dev/null +++ b/app/migrations/0375_alter_internal_entry_ref.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2026-02-09 10:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0374_payment_schedule_link_payment_schedule_ref_code'), + ] + + operations = [ + migrations.AlterField( + model_name='internal_entry', + name='ref', + field=models.CharField(max_length=30, null=True, unique=True), + ), + ] diff --git a/app/migrations/0376_remove_payment_schedule_link_and_more.py b/app/migrations/0376_remove_payment_schedule_link_and_more.py new file mode 100644 index 00000000..f91071df --- /dev/null +++ b/app/migrations/0376_remove_payment_schedule_link_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.7 on 2026-02-10 03:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0375_alter_internal_entry_ref'), + ] + + operations = [ + migrations.RemoveField( + model_name='payment_schedule', + name='link', + ), + migrations.RemoveField( + model_name='payment_schedule', + name='ref_code', + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('link', models.CharField(max_length=100, null=True)), + ('ref_code', models.CharField(max_length=30, null=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=35)), + ('note', models.CharField(max_length=100, null=True)), + ('payment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoice', to='app.payment_schedule')), + ], + options={ + 'db_table': 'invoice', + }, + ), + ] diff --git a/app/models.py b/app/models.py index 348bd6b2..f4771577 100644 --- a/app/models.py +++ b/app/models.py @@ -1667,7 +1667,7 @@ class Internal_Entry(AutoCodeModel): inputer = models.ForeignKey(User, null=False, related_name='+', on_delete=models.PROTECT) account = models.ForeignKey(Internal_Account, null=False, related_name='+', on_delete=models.PROTECT) date = models.DateField(null=False) - ref = models.CharField(max_length=30, null=True) + ref = models.CharField(max_length=30, null=True, unique=True) customer = models.ForeignKey(Customer, null=True, related_name='entrycus', on_delete=models.PROTECT) product = models.ForeignKey(Product, null=True, related_name='+', on_delete=models.PROTECT) allocation_amount = models.DecimalField(null=True, max_digits=35, decimal_places=2) @@ -1694,8 +1694,6 @@ 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) @@ -1722,6 +1720,19 @@ class Payment_Schedule(AutoCodeModel): class Meta: db_table = 'payment_schedule' +class Invoice(AutoCodeModel): + code_prefix = "HD" + code_padding = 5 + link = models.CharField(max_length=100, null=True) + ref_code = models.CharField(max_length=30, null=True) + amount = models.DecimalField(max_digits=35, decimal_places=2) + payment = models.ForeignKey(Payment_Schedule, null=False, related_name='invoice', on_delete=models.PROTECT) + note = models.CharField(max_length=100, null=True) + + class Meta: + db_table = 'invoice' + + class Phone_Otp(models.Model): code = models.CharField(max_length=30, null=False, unique=True) diff --git a/app/payment.py b/app/payment.py index f2865cd3..37ea2672 100644 --- a/app/payment.py +++ b/app/payment.py @@ -262,7 +262,7 @@ def allocate_payment_to_schedules(product_id): if amount_remain_after > 0: sch.ovd_days = max(0, (today - sch.to_date).days) else : - sch.ovd_days = days_for_trace + days_from_entry_to_today + sch.ovd_days = days_for_trace print(f"Lai la : {penalty_to_this_entry + additional_penalty_to_today} = {sch.penalty_amount}") # Ghi Trace sch_entry_list = sch.entry or [] @@ -336,6 +336,7 @@ def allocate_penalty_reduction(product_id): """ Xử lý miễn giảm tiền phạt cho một sản phẩm cụ thể. Quét các entry CR từ tài khoản miễn lãi (id=5) có allocation_remain > 0. + Sau khi miễn giảm, kiểm tra và đóng lịch/giao dịch nếu đủ điều kiện. """ if not product_id: return {"status": "no_product", "message": "Không có product_id"} @@ -351,10 +352,7 @@ def allocate_penalty_reduction(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": errors} txn = booked.transaction @@ -369,10 +367,7 @@ def allocate_penalty_reduction(product_id): 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} reduction_entries = Internal_Entry.objects.select_for_update().filter( product=product, @@ -385,53 +380,44 @@ def allocate_penalty_reduction(product_id): return { "status": "success", "message": "Không có entry miễn lãi cần xử lý", - "updated_schedules": [], - "updated_entries": [], - "errors": [] + "updated_schedules": [], "updated_entries": [], "errors": [] } schedules = Payment_Schedule.objects.select_for_update().filter( txn_detail=txn_detail, - status=1 + status__id=1 # Chỉ xử lý các lịch chưa thanh toán ).order_by('cycle', 'from_date') if not schedules.exists(): return { "status": "success", "message": "Không có lịch thanh toán cần miễn lãi", - "updated_schedules": [], - "updated_entries": [], - "errors": [] + "updated_schedules": [], "updated_entries": [], "errors": [] } for entry in reduction_entries: remaining_reduce = Decimal(str(entry.allocation_remain)) - - if remaining_reduce <= 0: - continue + if remaining_reduce <= 0: continue entry_allocation_detail = entry.allocation_detail or [] entry_reduction_allocated = Decimal('0') for schedule in schedules: - if remaining_reduce <= 0: - break + if remaining_reduce <= 0: break + # Chỉ miễn giảm cho các lịch còn nợ phạt current_penalty_remain = Decimal(str(schedule.penalty_remain or 0)) - current_penalty_reduce = Decimal(str(schedule.penalty_reduce or 0)) - current_remain_amount = Decimal(str(schedule.remain_amount or 0)) + if current_penalty_remain <= 0: continue to_reduce = min(remaining_reduce, current_penalty_remain) - - if to_reduce <= 0: - continue + if to_reduce <= 0: continue remaining_reduce -= to_reduce entry_reduction_allocated += to_reduce - schedule.penalty_reduce = current_penalty_reduce + to_reduce - schedule.penalty_remain = current_penalty_remain - to_reduce - schedule.remain_amount = current_remain_amount - to_reduce + schedule.penalty_reduce = (schedule.penalty_reduce or Decimal('0')) + to_reduce + schedule.penalty_remain -= to_reduce + schedule.remain_amount -= to_reduce sch_entry_list = schedule.entry or [] sch_entry_list.append({ @@ -442,18 +428,23 @@ def allocate_penalty_reduction(product_id): }) schedule.entry = safe_json_serialize(sch_entry_list) - schedule.save(update_fields=[ - 'penalty_reduce', 'penalty_remain', 'remain_amount', 'entry' - ]) + schedule.save(update_fields=['penalty_reduce', 'penalty_remain', 'remain_amount', 'entry']) + + # KIỂM TRA ĐỂ ĐÓNG LỊCH + if schedule.remain_amount <= 0 and schedule.amount_remain <= 0: + try: + paid_status = Payment_Status.objects.get(id=2) + schedule.status = paid_status + schedule.save(update_fields=['status']) + except Payment_Status.DoesNotExist: + errors.append("Không tìm thấy Payment_Status id=2") if schedule.id not in updated_schedules: updated_schedules.append(schedule.id) entry_allocation_detail.append({ - "schedule_id": schedule.id, - "schedule_code": schedule.code, - "amount": float(to_reduce), - "type": "REDUCTION", + "schedule_id": schedule.id, "schedule_code": schedule.code, + "amount": float(to_reduce), "type": "REDUCTION", "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S") }) @@ -465,6 +456,23 @@ def allocate_penalty_reduction(product_id): if entry.id not in updated_entries: updated_entries.append(entry.id) + # KIỂM TRA ĐỂ ĐÓNG TOÀN BỘ GIAO DỊCH + try: + txn_detail.refresh_from_db() + 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: + paid_txn_status = Transaction_Status.objects.get(id=2) + txn_detail.status = paid_txn_status + txn.status = paid_txn_status + txn_detail.save(update_fields=['status']) + txn.save(update_fields=['status']) + print(f"Transaction for product {product_id} closed after penalty reduction.") + except Transaction_Status.DoesNotExist: + errors.append("Không tìm thấy Transaction_Status id=2") + except Exception as e: + errors.append(f"Lỗi khi đóng transaction sau miễn giảm: {str(e)}") + except Exception as exc: errors.append(str(exc)) import traceback @@ -479,17 +487,180 @@ def allocate_penalty_reduction(product_id): # ========================================================================================== +def reset_product_state_before_allocation(product_id): + """ + Reset toàn bộ trạng thái công nợ của sản phẩm về ban đầu TRƯỚC KHI chạy phân bổ. + Sử dụng logic tính toán giống delete_entry nhưng không xóa entry và không hoàn tác tài khoản. + """ + with transaction.atomic(): + try: + product = Product.objects.get(id=product_id) + booked = Product_Booked.objects.filter(product=product).first() + if not (booked and booked.transaction): + print(f"Reset: Không tìm thấy transaction cho product {product_id}") + return + + 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 not txn_detail: + print(f"Reset: Không tìm thấy txn_detail cho product {product_id}") + return + + # ================================================================= + # Bước 1: Reset các lịch thanh toán theo công thức (GIỐNG delete_entry) + # ================================================================= + all_schedules = Payment_Schedule.objects.select_for_update().filter(txn_detail=txn_detail) + unpaid_status = Payment_Status.objects.get(id=1) + + for schedule in all_schedules: + current_amount_remain = Decimal(str(schedule.amount_remain or 0)) + + # Tính tổng principal đã phân bổ vào đúng lịch này từ tất cả entry CR + principal_allocated_to_schedule = Decimal('0') + for e in Internal_Entry.objects.filter(product_id=product_id, type__code='CR'): + for alloc in (e.allocation_detail or []): + if alloc.get('schedule_id') == schedule.id: + principal_allocated_to_schedule += Decimal(str(alloc.get('principal', 0))) + + # Reset theo công thức - GIỐNG delete_entry + schedule.entry = [] + schedule.amount_remain = current_amount_remain + principal_allocated_to_schedule + schedule.remain_amount = schedule.amount_remain + schedule.paid_amount = schedule.amount - schedule.amount_remain + schedule.penalty_paid = Decimal('0') + schedule.penalty_reduce = Decimal('0') + schedule.penalty_amount = Decimal('0') + schedule.penalty_remain = Decimal('0') + schedule.ovd_days = 0 + schedule.status = unpaid_status + + schedule.save(update_fields=[ + 'entry', 'paid_amount', 'amount_remain', 'remain_amount', + 'penalty_paid', 'penalty_reduce', 'penalty_remain', 'ovd_days', 'status', 'penalty_amount' + ]) + + # ================================================================= + # Bước 2: Reset allocation của tất cả entry CR của sản phẩm (GIỐNG delete_entry) + # ================================================================= + all_cr_entries = Internal_Entry.objects.filter( + product_id=product_id, + type__code='CR' + ) + for e in all_cr_entries: + e.allocation_detail = [] + e.allocation_amount = Decimal('0') + e.allocation_remain = Decimal(str(e.amount)) + e.save(update_fields=[ + 'allocation_detail', 'allocation_amount', 'allocation_remain' + ]) + + # ================================================================= + # Bước 3: Đặt lại Transaction_Detail từ các lịch và LƯU TRƯỚC (GIỐNG delete_entry) + # ================================================================= + if txn_detail: + # Lấy tổng paid của các lịch THUỘC detail này + schedules_of_this_detail = Payment_Schedule.objects.filter(txn_detail=txn_detail) + detail_total_paid = Decimal('0') + + for sch in schedules_of_this_detail: + paid = Decimal(str(sch.paid_amount or 0)) + detail_total_paid += paid + + # Cập nhật Transaction_Detail + txn_detail.amount_received = detail_total_paid + txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0)) - detail_total_paid + + # ===== LƯU NGAY TRANSACTION_DETAIL ===== + txn_detail.save(update_fields=['amount_received', 'amount_remaining']) + txn_detail.refresh_from_db() + + # Đặt lại status về unpaid nếu còn nợ + if txn_detail.amount_remaining > 0: + try: + unpaid_txn_status = Transaction_Status.objects.get(id=1) + txn_detail.status = unpaid_txn_status + txn_detail.save(update_fields=['status']) + except: + pass + + # ================================================================= + # Bước 4: SAU KHI LƯU TXN_DETAIL, Query lại TẤT CẢ DETAIL và tính Transaction (GIỐNG delete_entry) + # ================================================================= + # Query lại TẤT CẢ detail sau khi đã lưu + all_details = Transaction_Detail.objects.filter(transaction=txn) + + txn_total_received = Decimal('0') + txn_total_deposit_received = Decimal('0') + + for detail in all_details: + # Refresh để lấy giá trị mới nhất từ DB + detail.refresh_from_db() + txn_total_received += Decimal(str(detail.amount_received or 0)) + + # Tính deposit từ các lịch deposit thuộc detail này + deposit_schedules = Payment_Schedule.objects.filter( + txn_detail=detail, + type_id=1 + ) + for dep_sch in deposit_schedules: + txn_total_deposit_received += Decimal(str(dep_sch.paid_amount or 0)) + + # Cập nhật Transaction - SỬA: amount -> sale_price + txn.amount_received = txn_total_received + txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn_total_received # ← SỬA ĐÂY + + if hasattr(txn, 'deposit_received'): + txn.deposit_received = txn_total_deposit_received + if hasattr(txn, 'deposit_remaining') and hasattr(txn, 'deposit_amount'): + txn.deposit_remaining = Decimal(str(txn.deposit_amount or 0)) - txn_total_deposit_received + + # ===== LƯU TRANSACTION ===== + txn.save(update_fields=['amount_received', 'amount_remain', 'deposit_received', 'deposit_remaining']) + txn.refresh_from_db() + + # Đặt lại status về unpaid nếu còn nợ + if txn.amount_remain > 0: + try: + unpaid_txn_status = Transaction_Status.objects.get(id=1) + txn.status = unpaid_txn_status + txn.save(update_fields=['status']) + except: + pass + + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Đã reset thành công trạng thái cho product_id={product_id}") + + except Exception as e: + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Lỗi khi reset product state for product_id={product_id}: {str(e)}") + import traceback + print(traceback.format_exc()) + + def background_allocate(product_id): - """Background task để chạy allocation sau khi tạo entry""" + """ + Background task để chạy allocation. + Luồng xử lý: Reset toàn bộ -> Phân bổ tiền trả nợ -> Phân bổ miễn giảm. + """ try: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation started for product_id={product_id}") + # 1. Reset toàn bộ trạng thái công nợ của sản phẩm về gốc + reset_product_state_before_allocation(product_id) + + # 2. Chạy phân bổ thanh toán (tiền khách trả) normal_result = allocate_payment_to_schedules(product_id) + + # 3. Chạy phân bổ miễn giảm (tiền công ty giảm cho khách) 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}:") - print("Normal:", normal_result) - print("Reduction:", reduction_result) + print("Normal allocation result:", normal_result) + print("Penalty reduction result:", reduction_result) except Exception as e: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation error for product_id={product_id}: {str(e)}") @@ -736,27 +907,7 @@ def delete_entry(request): if booked and booked.transaction: txn = booked.transaction - # Lấy tất cả lịch liên quan - all_schedules = Payment_Schedule.objects.filter( - txn_detail__transaction=txn - ) - - # Tính tổng paid_amount từ các lịch (trước khi phân bổ lại) - total_paid_all = Decimal('0') - total_remain_all = Decimal('0') - total_deposit_paid = Decimal('0') - - for sch in all_schedules: - paid = Decimal(str(sch.paid_amount or 0)) - remain = Decimal(str(sch.amount_remain or 0)) - - total_paid_all += paid - total_remain_all += remain - - if sch.type_id == 1: # type=1 là lịch đặt cọc - total_deposit_paid += paid - - # Tính lại Transaction_Detail + # Tính lại Transaction_Detail hiện tại TRƯỚC try: current = Transaction_Current.objects.get(transaction=txn) txn_detail = current.detail @@ -764,10 +915,23 @@ def delete_entry(request): txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by('-create_time').first() if txn_detail: - # amount_received = amount_remaining hiện tại + tổng paid_amount từ lịch - txn_detail.amount_received = Decimal(str(txn_detail.amount_remaining or 0)) + total_paid_all - # amount_remaining = amount - amount_received vừa tính - txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0)) - txn_detail.amount_received + # Lấy tổng paid của các lịch THUỘC detail này + schedules_of_this_detail = Payment_Schedule.objects.filter(txn_detail=txn_detail) + detail_total_paid = Decimal('0') + detail_deposit_paid = Decimal('0') + + for sch in schedules_of_this_detail: + paid = Decimal(str(sch.paid_amount or 0)) + detail_total_paid += paid + + if sch.type_id == 1: # deposit + detail_deposit_paid += paid + + # Cập nhật Transaction_Detail + txn_detail.amount_received = detail_total_paid + txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0)) - detail_total_paid + + # ===== LƯU NGAY ===== txn_detail.save(update_fields=['amount_received', 'amount_remaining']) txn_detail.refresh_from_db() txn_detail_updated = True @@ -780,12 +944,34 @@ def delete_entry(request): except: pass - # Tính lại Transaction - đặt lại các trường về trạng thái ban đầu - txn.amount_received = Decimal('0') - txn.amount_remain = Decimal(str(txn.amount or 0)) # amount gốc hợp đồng + # ====== SAU KHI LƯU HẾT TXN_DETAIL, MỚI TÍNH TRANSACTION ====== + # Query lại TẤT CẢ detail sau khi đã lưu + all_details = Transaction_Detail.objects.filter(transaction=txn) + + txn_total_received = Decimal('0') + txn_total_deposit_received = Decimal('0') + + for detail in all_details: + # Lấy giá trị mới nhất từ DB (đã refresh) + detail.refresh_from_db() + txn_total_received += Decimal(str(detail.amount_received or 0)) + + # Tính deposit từ các lịch deposit thuộc detail này + deposit_schedules = Payment_Schedule.objects.filter( + txn_detail=detail, + type_id=1 + ) + for dep_sch in deposit_schedules: + txn_total_deposit_received += Decimal(str(dep_sch.paid_amount or 0)) + + # Cập nhật Transaction + txn.amount_received = txn_total_received + txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn_total_received + if hasattr(txn, 'deposit_received'): - txn.deposit_received = Decimal('0') - txn.deposit_remaining = txn.deposit_amount if hasattr(txn, 'deposit_amount') else Decimal('0') + txn.deposit_received = txn_total_deposit_received + if hasattr(txn, 'deposit_remaining') and hasattr(txn, 'deposit_amount'): + txn.deposit_remaining = Decimal(str(txn.deposit_amount or 0)) - txn_total_deposit_received txn.save(update_fields=['amount_received', 'amount_remain', 'deposit_received', 'deposit_remaining']) txn.refresh_from_db()