diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index 7abe7486..2e6ac9b1 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 e773bf65..25ea5e11 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 = False +DEBUG = True ALLOWED_HOSTS = ['*'] @@ -79,7 +79,7 @@ ASGI_APPLICATION = 'api.asgi.application' # https://docs.djangoproject.com/en/4.1/ref/settings/#databases #prod:5.223.52.193 dev:5.223.42.146 -MODE = 'prod' +MODE = 'dev' DBHOST = '138.199.203.34' if MODE == 'prod' else '138.199.203.34' DATABASES = { diff --git a/api/urls.py b/api/urls.py index 5a033a05..fa8f261b 100644 --- a/api/urls.py +++ b/api/urls.py @@ -14,7 +14,7 @@ Including another URLconf 2. Add a URL to urlpatterns: re_path('blog/', include('blog.urls')) """ from django.urls import re_path -from app import views, cob, payment, cleardata, email, backup, server, importdata +from app import views, cob, cleardata, email, backup, server, importdata urlpatterns = [ @@ -39,8 +39,6 @@ urlpatterns = [ re_path('check-pin/$', views.check_pin), re_path('password/(?P.+)/$', views.get_password), re_path('exportcsv/(?P.+)/$', views.export_csv), - re_path('account-entry/$', payment.account_entry), - re_path('account-multi-entry/$', payment.account_multi_entry), re_path('close-of-business/$', cob.close_of_business), re_path('data-deletion/', cleardata.data_deletion), re_path('send-email/$', email.send_email), @@ -52,6 +50,5 @@ 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('delete-entry/$', payment.delete_entry) + re_path('email-preview/$', views.preview_email_template) ] \ No newline at end of file diff --git a/app/__pycache__/signals.cpython-313.pyc b/app/__pycache__/signals.cpython-313.pyc index 1baafbd4..4459e0ff 100644 Binary files a/app/__pycache__/signals.cpython-313.pyc and b/app/__pycache__/signals.cpython-313.pyc differ diff --git a/app/__pycache__/views.cpython-313.pyc b/app/__pycache__/views.cpython-313.pyc index 09fd6680..ae8c1564 100644 Binary files a/app/__pycache__/views.cpython-313.pyc and b/app/__pycache__/views.cpython-313.pyc differ diff --git a/app/payment.py b/app/payment.py deleted file mode 100644 index 70b9fc69..00000000 --- a/app/payment.py +++ /dev/null @@ -1,1041 +0,0 @@ -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, date -from decimal import Decimal -from django.db.models import F -import threading -import json - - -# ========================================================================================== -# HELPER FUNCTIONS -# ========================================================================================== - -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): - try: - obj = Model.objects.latest('id') - val = 1 if obj is None else obj.id + 1 - except Exception: - val = 1 - length = len(str(val)) - 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: - user = User.objects.get(id=userid) - entry_type = Entry_Type.objects.get(code=type) - entry_category = Entry_Category.objects.get(id=category) - system_date = date if date else datetime.now().strftime("%Y-%m-%d") - amount = float(amount) - - with transaction.atomic(): - account = Internal_Account.objects.select_for_update().get(code=code) - start_balance = account.balance or 0 - - if entry_type.code == 'DR' and start_balance < amount: - return {'error': 'Số dư không đủ để thực hiện giao dịch.'}, None - - if entry_type.code == 'CR': - account.balance += amount - else: - account.balance -= amount - - account.save() - account.refresh_from_db() - new_balance = account.balance - - entry = Internal_Entry.objects.create( - category=entry_category, - content=content, - amount=amount, - inputer=user, - approver=user, - type=entry_type, - balance_before=start_balance, - balance_after=new_balance, - account=account, - date=system_date, - ref=ref, - product=None if product is None else Product.objects.get(id=product), - customer=None if customer is None else Customer.objects.get(id=customer), - allocation_amount=Decimal('0'), - allocation_remain=Decimal(str(amount)) if entry_type.code == 'CR' else Decimal('0'), - allocation_detail=[] - ) - - text = 'id,account__currency__code,ref,balance_before,balance_after,code,account,account__code,account__branch__name,account__type__name,date,amount,content,inputer,inputer__fullname,approver,approver__fullname,create_time,update_time,type,type__code,type__name,allocation_amount,allocation_remain' - fields = text.split(',') - response_data = Internal_Entry.objects.filter(id=entry.id).values(*fields).first() - return response_data, entry - - except User.DoesNotExist: - return {'error': f"Người dùng với ID {userid} không tồn tại."}, None - except Internal_Account.DoesNotExist: - return {'error': f"Tài khoản nội bộ với mã '{code}' không tồn tại."}, None - except Entry_Type.DoesNotExist: - return {'error': f"Loại bút toán với mã '{type}' không tồn tại."}, None - except Entry_Category.DoesNotExist: - return {'error': f"Danh mục bút toán với ID '{category}' không tồn tại."}, None - except Exception as e: - return {'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"}, None - - -# ========================================================================================== -def get_allocation_rule(): - return 'principal-fee' - - -# ========================================================================================== - -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_original_amount_remain_from_trace(schedule): - """ - Lấy amount_remain gốc của lịch (trước khi có bất kỳ phân bổ nào) từ trace. - - Quy tắc: - - Trace PAYMENT được ghi theo thứ tự thời gian, mỗi record có field - 'amount_remain_before' = số tiền còn lại của lịch TRƯỚC KHI entry đó phân bổ vào. - - Trace đầu tiên (sort by date asc) → amount_remain_before = trạng thái gốc ban đầu. - - Nếu trace rỗng (lịch chưa từng được phân bổ) → trả về None, - caller giữ nguyên amount_remain hiện tại. - """ - entry_list = schedule.entry - if not entry_list: - return None - - payment_traces = [ - e for e in entry_list - if e.get('type') == 'PAYMENT' and e.get('amount_remain_before') is not None - ] - if not payment_traces: - return None - - # Lấy trace PAYMENT sớm nhất theo date - payment_traces.sort(key=lambda e: e.get('date', '')) - first_trace = payment_traces[0] - return Decimal(str(first_trace['amount_remain_before'])) - - -# ========================================================================================== -def reset_schedules_to_pristine(all_schedules, unpaid_status): - """ - Đưa tất cả lịch về trạng thái trước khi có bất kỳ phân bổ nào. - - amount_remain gốc được đọc từ trace đầu tiên (field 'amount_remain_before'). - Nếu lịch chưa có trace → chưa từng phân bổ → giữ nguyên amount_remain hiện tại. - Xóa sạch trace (entry=[]) vì toàn bộ allocation sẽ được tính lại từ đầu. - """ - for schedule in all_schedules: - original_amount_remain = get_original_amount_remain_from_trace(schedule) - - if original_amount_remain is None: - # Lịch chưa từng được phân bổ → amount_remain hiện tại chính là gốc - original_amount_remain = Decimal(str(schedule.amount_remain or 0)) - - schedule.entry = [] - schedule.amount_remain = original_amount_remain - schedule.remain_amount = original_amount_remain - # paid_amount = tổng amount của lịch - phần còn lại gốc - schedule.paid_amount = max( - Decimal('0'), - Decimal(str(schedule.amount or 0)) - original_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' - ]) - - -# ========================================================================================== -def reset_cr_entries_allocation(product_id, exclude_entry_id=None): - """ - Reset allocation của tất cả entry CR của sản phẩm về trạng thái chưa phân bổ. - Nếu exclude_entry_id được truyền (trường hợp xóa entry), bỏ qua entry đó. - """ - qs = Internal_Entry.objects.filter(product_id=product_id, type__code='CR') - if exclude_entry_id: - qs = qs.exclude(id=exclude_entry_id) - - for e in qs: - 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']) - - -# ========================================================================================== -def close_paid_schedules(txn): - """ - Quét toàn bộ Payment_Schedule thuộc txn_detail và đóng (status=2) những lịch - đã được thanh toán đầy đủ. - - Điều kiện đóng một lịch: - - status_id == 1 (đang mở/chưa đóng) - - amount_remain <= 0 (đã trả hết gốc) - - penalty_remain <= 0 (đã trả hết phạt) - - Hàm không raise exception — lỗi từng lịch được bỏ qua và in ra log, - các lịch còn lại vẫn được xử lý bình thường. - - Returns: - list[int]: danh sách id các lịch vừa được đóng. - """ - all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - closed_ids = [] - try: - paid_status = Payment_Status.objects.get(id=2) - except Payment_Status.DoesNotExist: - print("[close_paid_schedules] Không tìm thấy Payment_Status id=2, bỏ qua.") - return closed_ids - - for txn_detail in all_txn_details: - schedules = Payment_Schedule.objects.filter( - txn_detail=txn_detail, - ) - - for sch in schedules: - try: - - if sch.remain_amount <= 0: - sch.status = paid_status - sch.save(update_fields=['status']) - closed_ids.append(sch.id) - except Exception as e: - print(f"[close_paid_schedules] Lỗi khi đóng schedule {sch.id}: {e}") - print(f"[close_paid_schedules] Đã đóng {len(closed_ids)} lịch") - return closed_ids - -def recalc_txn_from_schedules(txn, all_txn_details, paid_txn_status): - """ - Tính lại Transaction và tất cả Transaction_Detail từ trạng thái hiện tại của các lịch. - - Quy tắc: - - Chỉ cập nhật status của Transaction_Detail thành paid (2) nếu: - - Đây là detail HIỆN TẠI (current) - - status_id HIỆN TẠI là 1 (unpaid/pending) - - amount_remaining <= 0 - - Không còn lịch thanh toán nào đang pending (status=1) - - Nếu status đã là 3, 6 hoặc các trạng thái khác → giữ nguyên, không thay đổi - - txn_total_received chỉ cộng từ các detail đã hoàn thành (status=2) - HOẶC detail current đang đủ điều kiện đóng - """ - txn_total_received = Decimal('0') - - # Lấy current detail một lần để tránh query lặp - current = Transaction_Current.objects.filter(transaction=txn).first() - current_detail = current.detail if current else None - - for detail in all_txn_details: - detail.refresh_from_db() - - # Tính tổng đã trả từ các lịch của detail này - detail_schedules = Payment_Schedule.objects.filter(txn_detail=detail) - detail_paid = sum(Decimal(str(s.paid_amount or 0)) for s in detail_schedules) - - detail.amount_received = detail_paid - detail.amount_remaining = Decimal(str(detail.amount or 0)) - detail_paid - detail.save(update_fields=['amount_received', 'amount_remaining']) - - # ==================================================== - # Chỉ xử lý đóng status cho detail HIỆN TẠI (current) - # ==================================================== - if detail == current_detail: - txn_total_received += detail_paid - has_pending = Payment_Schedule.objects.filter( - txn_detail=detail, - status__id=1 - ).exists() - print(f"status current: {detail.status_id}") - if ( - detail.amount_remaining <= 0 - and not has_pending - and detail.status_id == 1 - ): - if paid_txn_status: - detail.status = paid_txn_status # status=2 (paid) - detail.save(update_fields=['status']) - print(f"[recalc] Đóng Transaction_Detail {detail.id} → status=2 (paid)") - - - - - # Cập nhật Transaction - txn.amount_received = txn_total_received - txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn_total_received - txn.save(update_fields=['amount_received', 'amount_remain']) - txn.refresh_from_db() - -# ========================================================================================== - -# Mapping phase_id → DAILY_PENALTY_RATE -# Phase 1, 2, 4: 0.03%/ngày | Các phase khác: 0.05%/ngày -PENALTY_RATE_LOW_PHASES = [1, 2, 4] -PENALTY_RATE_LOW = Decimal('0.0003') # 0.03% -PENALTY_RATE_HIGH = Decimal('0.0005') # 0.05% - -def get_penalty_rate_for_schedule(schedule): - """ - Trả về DAILY_PENALTY_RATE tương ứng với phase của Transaction_Detail gắn với lịch. - - Phase 1, 2, 4 → 0.03%/ngày (0.0003) - - Các phase khác → 0.05%/ngày (0.0005) - """ - try: - phase_id = schedule.txn_detail.phase_id - print(f"phase_id: {phase_id}") - if phase_id in PENALTY_RATE_LOW_PHASES: - return PENALTY_RATE_LOW - return PENALTY_RATE_HIGH - except Exception: - # Nếu không lấy được phase thì dùng mức cao (an toàn hơn) - return PENALTY_RATE_HIGH - - -# ========================================================================================== -def allocate_payment_to_schedules(product_id): - if not product_id: - return {"status": "no_product", "message": "Không có product_id"} - - updated_schedules = [] - paid_payment_status = Payment_Status.objects.filter(id=2).first() - paid_txn_status = Transaction_Status.objects.filter(id=2).first() - today = datetime.now().date() - - with transaction.atomic(): - try: - product = Product.objects.get(id=product_id) - booked = Product_Booked.objects.filter(product=product).first() - if not booked or not booked.transaction: - return {"status": "error", "errors": ["Không tìm thấy Transaction"]} - - txn = booked.transaction - - # Lấy TẤT CẢ detail của transaction - all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - - # Lấy các bút toán CR còn dư tiền (trừ tài khoản miễn lãi id=5) - entries_with_remain = Internal_Entry.objects.select_for_update().filter( - product=product, - type__code='CR', - allocation_remain__gt=0 - ).exclude(account__id=5).order_by('date', 'create_time') - - if not entries_with_remain.exists(): - return {"status": "success", "message": "Không có tiền để phân bổ"} - - # Lấy lịch của TẤT CẢ detail, sắp xếp theo detail cũ → mới, rồi cycle - schedules = Payment_Schedule.objects.select_for_update().filter( - txn_detail__in=all_txn_details, - ).order_by('txn_detail__create_time', 'cycle', 'from_date') - - total_principal_allocated = Decimal('0') - total_penalty_allocated = Decimal('0') - - for entry in entries_with_remain: - entry_date = entry.date - if isinstance(entry_date, str): - entry_date = datetime.strptime(entry_date, "%Y-%m-%d").date() - - remaining = Decimal(str(entry.allocation_remain)) - if remaining <= 0: - continue - - entry_allocation_detail = entry.allocation_detail or [] - entry_principal_allocated = Decimal('0') - entry_penalty_allocated = Decimal('0') - - for sch in schedules: - if remaining <= 0: - break - - # --- XÁC ĐỊNH LÃI PHẠT THEO PHASE --- - DAILY_PENALTY_RATE = get_penalty_rate_for_schedule(sch) - print(f"DAILY_PENALTY_RATE: {DAILY_PENALTY_RATE}") - - current_amount_remain = Decimal(str(sch.amount_remain or 0)) - - # --- BƯỚC 1: LẤY LÃI TÍCH LŨY TỪ TRACE --- - last_entry_date = None - accumulated_penalty_to_last = Decimal('0') - - if sch.entry: - for e in sch.entry: - if e.get('type') == 'PAYMENT': - e_date = datetime.strptime(e['date'], "%Y-%m-%d").date() - if e_date <= entry_date: - if e.get('penalty_to_this_entry') is not None: - accumulated_penalty_to_last = Decimal(str(e['penalty_to_this_entry'])) - if not last_entry_date or e_date > last_entry_date: - last_entry_date = e_date - - # --- BƯỚC 2: TÍNH LÃI PHÁT SINH MỚI --- - penalty_added_to_entry = Decimal('0') - days_for_trace = 0 - if current_amount_remain > 0: - if last_entry_date: - days_between = max(0, (entry_date - last_entry_date).days) - penalty_added_to_entry = current_amount_remain * Decimal(days_between) * DAILY_PENALTY_RATE - days_for_trace = days_between - else: - days_overdue_to_entry = max(0, (entry_date - sch.to_date).days) - penalty_added_to_entry = current_amount_remain * Decimal(days_overdue_to_entry) * DAILY_PENALTY_RATE - days_for_trace = days_overdue_to_entry - - penalty_to_this_entry = accumulated_penalty_to_last + penalty_added_to_entry - penalty_to_pay_now = max( - Decimal('0'), - penalty_to_this_entry - Decimal(str(sch.penalty_paid or 0)) - ) - - # --- BƯỚC 3: PHÂN BỔ TIỀN --- - to_principal = min(remaining, current_amount_remain) - remaining -= to_principal - amount_remain_after = current_amount_remain - to_principal - - to_penalty = min(remaining, penalty_to_pay_now) - remaining -= to_penalty - - allocated_here = to_principal + to_penalty - - if allocated_here <= 0: - continue - - entry_principal_allocated += to_principal - entry_penalty_allocated += to_penalty - - # --- BƯỚC 4: LÃI DỰ PHÒNG ĐẾN NAY --- - days_from_entry_to_today = max(0, (today - entry_date).days) - additional_penalty_to_today = Decimal('0') - if amount_remain_after > 0: - additional_penalty_to_today = ( - amount_remain_after * Decimal(days_from_entry_to_today) * DAILY_PENALTY_RATE - ) - - # --- CẬP NHẬT DỮ LIỆU --- - sch.paid_amount = Decimal(str(sch.paid_amount or 0)) + to_principal - sch.penalty_paid = Decimal(str(sch.penalty_paid or 0)) + to_penalty - sch.amount_remain = amount_remain_after - sch.penalty_amount = penalty_to_this_entry + additional_penalty_to_today - sch.penalty_remain = max(Decimal('0'), sch.penalty_amount - sch.penalty_paid) - sch.remain_amount = sch.amount_remain + sch.penalty_remain - - if amount_remain_after > 0: - sch.ovd_days = max(0, (today - sch.to_date).days) - else: - sch.ovd_days = days_for_trace - - # Ghi Trace - # Lưu ý: amount_remain_before là trạng thái của lịch TRƯỚC KHI entry này - # phân bổ vào. Trace đầu tiên (sớm nhất theo date) sẽ chứa giá trị gốc - # ban đầu của lịch, dùng để restore khi reset toàn bộ phân bổ. - sch_entry_list = sch.entry or [] - sch_entry_list.append({ - "type": "PAYMENT", - "code": entry.code, - "date": entry_date.strftime("%Y-%m-%d"), - "amount": float(allocated_here), - "principal": float(to_principal), - "penalty": float(to_penalty), - "penalty_added_to_entry": float(penalty_added_to_entry), - "penalty_to_this_entry": float(penalty_to_this_entry), - "amount_remain_before": float(current_amount_remain), - "amount_remain_after_allocation": float(amount_remain_after), - "DAILY_PENALTY_RATE": float(DAILY_PENALTY_RATE) - }) - sch.entry = sch_entry_list - - sch.save() - if sch.id not in updated_schedules: - updated_schedules.append(sch.id) - - entry_allocation_detail.append({ - "schedule_id": sch.id, - "amount": float(allocated_here), - "principal": float(to_principal), - "penalty": float(to_penalty) - }) - - # Cập nhật Entry nguồn - entry.allocation_amount = ( - (entry.allocation_amount or Decimal('0')) + - entry_principal_allocated + entry_penalty_allocated - ) - entry.allocation_remain = remaining - entry.allocation_detail = entry_allocation_detail - entry.save() - - total_principal_allocated += entry_principal_allocated - total_penalty_allocated += entry_penalty_allocated - - # Cập nhật Transaction và Transaction_Detail - if total_principal_allocated > 0 or total_penalty_allocated > 0: - close_paid_schedules(txn) - recalc_txn_from_schedules(txn, all_txn_details, paid_txn_status) - - return {"status": "success", "updated_schedules": updated_schedules} - - except Exception as exc: - import traceback - print(traceback.format_exc()) - return {"status": "error", "errors": [str(exc)]} - - -# ========================================================================================== -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"} - - updated_schedules = [] - updated_entries = [] - errors = [] - - with transaction.atomic(): - try: - product = Product.objects.get(id=product_id) - - booked = Product_Booked.objects.filter(product=product).first() - if not booked or not booked.transaction: - errors.append(f"Product {product_id}: Không tìm thấy Transaction") - return {"status": "error", "errors": errors} - - txn = booked.transaction - - # Lấy TẤT CẢ detail của transaction - all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - - reduction_entries = Internal_Entry.objects.select_for_update().filter( - product=product, - type__code='CR', - account__id=5, - allocation_remain__gt=0 - ).order_by('date', 'create_time') - - if not reduction_entries.exists(): - return { - "status": "success", - "message": "Không có entry miễn lãi cần xử lý", - "updated_schedules": [], "updated_entries": [], "errors": [] - } - - # Lấy TẤT CẢ lịch chưa thanh toán của tất cả detail - schedules = Payment_Schedule.objects.select_for_update().filter( - txn_detail__in=all_txn_details, - status__id=1 - ).order_by('txn_detail__create_time', '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": [] - } - - paid_payment_status = Payment_Status.objects.filter(id=2).first() - paid_txn_status = Transaction_Status.objects.filter(id=2).first() - - for entry in reduction_entries: - remaining_reduce = Decimal(str(entry.allocation_remain)) - 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 - - current_penalty_remain = Decimal(str(schedule.penalty_remain or 0)) - if current_penalty_remain <= 0: - continue - - to_reduce = min(remaining_reduce, current_penalty_remain) - if to_reduce <= 0: - continue - - remaining_reduce -= to_reduce - entry_reduction_allocated += 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({ - "type": "REDUCTION", - "code": entry.code, - "date": datetime.now().strftime("%Y-%m-%d"), - "amount": float(to_reduce) - }) - schedule.entry = safe_json_serialize(sch_entry_list) - - schedule.save(update_fields=[ - 'penalty_reduce', 'penalty_remain', 'remain_amount', 'entry' - ]) - - # Đóng lịch: chỉ khi status hiện tại là 1 - if schedule.status_id == 1 and 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", - "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }) - - 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) - - # Kiểm tra đóng transaction sau miễn giảm - # Dùng recalc_txn_from_schedules để đảm bảo nhất quán - # Nhưng KHÔNG ghi đè amount_received/amount_remain vì penalty reduction - # không thay đổi số tiền đã trả, chỉ thay đổi penalty - # → Chỉ kiểm tra đóng nếu đủ điều kiện - try: - txn.refresh_from_db() - for detail in all_txn_details: - detail.refresh_from_db() - # Chỉ đóng detail nếu status_id == 1 - if detail.status_id == 1: - has_pending_sch = Payment_Schedule.objects.filter( - txn_detail=detail, status__id=1 - ).exists() - if detail.amount_remaining <= 0 and not has_pending_sch: - if paid_txn_status: - detail.status = paid_txn_status - detail.save(update_fields=['status']) - - # Transaction không có field status — chỉ Transaction_Detail mới có - - 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 - print(traceback.format_exc()) - - return { - "status": "success" if not errors else "partial_failure", - "updated_schedules": updated_schedules, - "updated_entries": updated_entries, - "errors": errors - } - - -# ========================================================================================== -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ổ. - - Nguồn sự thật: sch.amount (số tiền gốc bất biến của mỗi lịch). - Sau khi reset: - - Tất cả lịch: amount_remain = sch.amount, paid = 0, penalty = 0, trace = [] - - Tất cả entry CR: allocation_remain = entry.amount, allocation_amount = 0, detail = [] - - Transaction & Detail: tính lại từ trạng thái lịch (= 0 đã trả) - """ - 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 - all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - - if not all_txn_details.exists(): - print(f"Reset: Không tìm thấy txn_detail nào cho product {product_id}") - return - - # Bước 1: Reset tất cả lịch về pristine (chưa có allocation) - all_schedules = Payment_Schedule.objects.select_for_update().filter( - txn_detail__in=all_txn_details - ) - unpaid_status = Payment_Status.objects.get(id=1) - reset_schedules_to_pristine(all_schedules, unpaid_status) - - # Bước 2: Reset allocation của tất cả entry CR - reset_cr_entries_allocation(product_id) - - # Bước 3: Tính lại Transaction và Transaction_Detail - # Sau reset, tất cả lịch đều unpaid → amount_received = 0 - txn_total_received = Decimal('0') - - for txn_detail in all_txn_details: - txn_detail.amount_received = Decimal('0') - txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0)) - txn_detail.save(update_fields=['amount_received', 'amount_remaining']) - - # Reset status detail về unpaid nếu còn nợ (chỉ nếu status khác 1) - if txn_detail.amount_remaining > 0 and txn_detail.status_id != 1: - try: - unpaid_txn_status = Transaction_Status.objects.get(id=1) - txn_detail.status = unpaid_txn_status - txn_detail.save(update_fields=['status']) - except Exception: - pass - - txn.amount_received = Decimal('0') - txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn.save(update_fields=['amount_received', 'amount_remain']) - - # Transaction không có field status — không cần reset - - print( - f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] " - f"Đã 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')}] " - f"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. - 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')}] " - f"Background allocation started for product_id={product_id}" - ) - - # 1. Reset toàn bộ trạng thái công nợ 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')}] " - f"Background allocation completed for product_id={product_id}:" - ) - 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')}] " - f"Background allocation error for product_id={product_id}: {str(e)}" - ) - import traceback - print(traceback.format_exc()) - - -# ========================================================================================== -# API VIEWS -# ========================================================================================== -@api_view(['POST']) -def account_entry(request): - """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( - code=request.data['code'], - amount=request.data['amount'], - content=request.data['content'], - type=request.data['type'], - category=request.data['category'], - userid=request.data['user'], - ref=ref, - product=request.data.get('product'), - customer=request.data.get('customer'), - date=request.data.get('date') - ) - - if 'error' in response_data: - return Response(response_data, status=400) - - product_id = created_entry.product_id if created_entry else None - - 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..." - }) - - if product_id: - def run_allocation(): - thread = threading.Thread( - target=background_allocate, args=(product_id,), daemon=True - ) - thread.start() - transaction.on_commit(run_allocation) - - return response - - -@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() - data_list = request.data.get('data', []) - - with transaction.atomic(): - for obj in data_list: - response_data, created_entry = account_entry_api( - code=obj['Tài khoản'], - amount=obj['amount'], - content=obj['content'], - type='CR', - category=obj['category'], - userid=request.data.get('user'), - ref=obj.get('ref'), - product=obj.get('product'), - customer=obj.get('customer'), - date=obj.get('date') - ) - - result.append(response_data) - - if created_entry and created_entry.product_id: - product_ids.add(created_entry.product_id) - - response = Response({ - "entries": result, - "message": ( - f"Bút toán đã tạo thành công. " - f"Phân bổ thanh toán đang chạy ngầm cho {len(product_ids)} sản phẩm..." - ) - }) - - if product_ids: - def run_allocations(): - for pid in product_ids: - thread = threading.Thread( - target=background_allocate, args=(pid,), daemon=True - ) - thread.start() - transaction.on_commit(run_allocations) - - return response - - 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) - - -@api_view(['POST']) -def delete_entry(request): - """ - Xóa bút toán. - Luồng: Reset sạch tất cả lịch & entry CR → Hoàn tác số dư tài khoản → - Xóa entry → Phân bổ lại toàn bộ (on_commit). - """ - entry_id = request.data.get('id') - - if not entry_id: - return Response({'error': 'Thiếu id bút toán'}, status=400) - - 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) - } - - if entry.type.code != 'CR': - return Response( - {'error': 'Hiện chỉ hỗ trợ xóa bút toán thu tiền (CR)'}, status=400 - ) - - product_id = entry.product_id - if not product_id: - return Response( - {'error': 'Bút toán không gắn với product nào'}, status=400 - ) - - # ================================================================= - # Bước 1: Reset tất cả lịch của transaction về pristine - # ================================================================= - booked = Product_Booked.objects.filter(product=entry.product).first() - if not (booked and booked.transaction): - return Response( - {'error': 'Không tìm thấy Transaction cho product này'}, status=400 - ) - - txn = booked.transaction - all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - - all_schedules = Payment_Schedule.objects.select_for_update().filter( - txn_detail__in=all_txn_details - ) - unpaid_status = Payment_Status.objects.get(id=1) - reset_schedules_to_pristine(all_schedules, unpaid_status) - - # ================================================================= - # Bước 2: Reset allocation của tất cả entry CR, - # NGOẠI TRỪ entry đang bị xóa (sẽ bị xóa sau) - # ================================================================= - reset_cr_entries_allocation(product_id, exclude_entry_id=entry_id) - - # ================================================================= - # Bước 3: Hoàn tác số dư tài khoản - # ================================================================= - 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']) - - # ================================================================= - # Bước 4: Xóa entry - # ================================================================= - entry.delete() - - # ================================================================= - # Bước 5: Đặt lại Transaction & Transaction_Detail về unpaid - # (amount_received = 0 vì tất cả lịch đã reset về pristine) - # ================================================================= - for txn_detail in all_txn_details: - txn_detail.amount_received = Decimal('0') - txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0)) - txn_detail.save(update_fields=['amount_received', 'amount_remaining']) - - if txn_detail.amount_remaining > 0 and txn_detail.status_id != 1: - try: - unpaid_txn_status = Transaction_Status.objects.get(id=1) - txn_detail.status = unpaid_txn_status - txn_detail.save(update_fields=['status']) - except Exception: - pass - - txn.amount_received = Decimal('0') - txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn.save(update_fields=['amount_received', 'amount_remain']) - - # Transaction không có field status — không cần reset - - # ================================================================= - # Bước 6: Phân bổ lại toàn bộ sản phẩm (on_commit) - # Chạy allocate trực tiếp (KHÔNG reset lại nữa, vì đã reset ở trên) - # ================================================================= - def trigger_reallocate(): - if product_id: - try: - allocate_payment_to_schedules(product_id) - allocate_penalty_reduction(product_id) - except Exception as exc: - import traceback - print(f"Lỗi khi re-allocate sau xóa: {exc}") - traceback.print_exc() - - transaction.on_commit(trigger_reallocate) - - return Response({ - 'success': True, - 'message': ( - 'Đã xóa bút toán, reset sạch tất cả lịch, ' - 'hoàn tác số dư tài khoản, đang phân bổ lại toàn bộ...' - ), - 'entry': entry_info, - '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