diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index 3841ed31..641c13af 100644 Binary files a/api/__pycache__/settings.cpython-313.pyc and b/api/__pycache__/settings.cpython-313.pyc differ diff --git a/app/__pycache__/payment.cpython-313.pyc b/app/__pycache__/payment.cpython-313.pyc index 294e8a22..07b13bbf 100644 Binary files a/app/__pycache__/payment.cpython-313.pyc and b/app/__pycache__/payment.cpython-313.pyc differ diff --git a/app/__pycache__/workflow_engine.cpython-313.pyc b/app/__pycache__/workflow_engine.cpython-313.pyc index 9e91cb75..68d6b545 100644 Binary files a/app/__pycache__/workflow_engine.cpython-313.pyc and b/app/__pycache__/workflow_engine.cpython-313.pyc differ diff --git a/app/payment.py b/app/payment.py index 37ea2672..ad1d6c18 100644 --- a/app/payment.py +++ b/app/payment.py @@ -134,16 +134,162 @@ def safe_json_serialize(obj): 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 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() + +# ========================================================================================== def allocate_payment_to_schedules(product_id): if not product_id: return {"status": "no_product", "message": "Không có product_id"} updated_schedules = [] - updated_entries = [] paid_payment_status = Payment_Status.objects.filter(id=2).first() paid_txn_status = Transaction_Status.objects.filter(id=2).first() today = datetime.now().date() - DAILY_PENALTY_RATE = Decimal('0.0005') # Giả định 0.05% + DAILY_PENALTY_RATE = Decimal('0.0005') with transaction.atomic(): try: @@ -153,9 +299,11 @@ def allocate_payment_to_schedules(product_id): return {"status": "error", "errors": ["Không tìm thấy Transaction"]} txn = booked.transaction - txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by('-create_time').first() - # Lấy các bút toán CR còn dư tiền (Sắp xếp chính xác để trừ theo thứ tự thời gian) + # 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', @@ -165,11 +313,10 @@ def allocate_payment_to_schedules(product_id): if not entries_with_remain.exists(): return {"status": "success", "message": "Không có tiền để phân bổ"} - # Lấy lịch nợ (chỉ lấy status=1) + # 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=txn_detail, - status__id=1 - ).order_by('cycle', 'from_date') + 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') @@ -180,31 +327,30 @@ def allocate_payment_to_schedules(product_id): entry_date = datetime.strptime(entry_date, "%Y-%m-%d").date() remaining = Decimal(str(entry.allocation_remain)) - if remaining <= 0: continue + if remaining <= 0: + continue entry_allocation_detail = entry.allocation_detail or [] entry_principal_allocated = Decimal('0') entry_penalty_allocated = Decimal('0') for sch in schedules: - if remaining <= 0: break + if remaining <= 0: + break current_amount_remain = Decimal(str(sch.amount_remain or 0)) - + # --- BƯỚC 1: LẤY LÃI TÍCH LŨY TỪ TRACE --- last_entry_date = None accumulated_penalty_to_last = Decimal('0') - + if sch.entry: for e in sch.entry: if e.get('type') == 'PAYMENT': e_date = datetime.strptime(e['date'], "%Y-%m-%d").date() - # Dùng <= để lấy được cả bút toán cùng ngày nộp trước đó if e_date <= entry_date: if e.get('penalty_to_this_entry') is not None: - # Luôn cập nhật để lấy con số lũy kế MỚI NHẤT accumulated_penalty_to_last = Decimal(str(e['penalty_to_this_entry'])) - if not last_entry_date or e_date > last_entry_date: last_entry_date = e_date @@ -221,11 +367,11 @@ def allocate_payment_to_schedules(product_id): penalty_added_to_entry = current_amount_remain * Decimal(days_overdue_to_entry) * DAILY_PENALTY_RATE days_for_trace = days_overdue_to_entry - # Tổng nợ lãi tại thời điểm này penalty_to_this_entry = accumulated_penalty_to_last + penalty_added_to_entry - - # QUAN TRỌNG: Nợ lãi thực tế cần trả ngay bây giờ - penalty_to_pay_now = max(Decimal('0'), penalty_to_this_entry - Decimal(str(sch.penalty_paid or 0))) + 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) @@ -234,10 +380,9 @@ def allocate_payment_to_schedules(product_id): to_penalty = min(remaining, penalty_to_pay_now) remaining -= to_penalty - + allocated_here = to_principal + to_penalty - # Nếu vẫn không có gì để phân bổ cho kỳ này, bỏ qua sang kỳ sau if allocated_here <= 0: continue @@ -246,11 +391,11 @@ def allocate_payment_to_schedules(product_id): # --- BƯỚC 4: LÃI DỰ PHÒNG ĐẾN NAY --- days_from_entry_to_today = max(0, (today - entry_date).days) - print(f" - Lai du phong: {days_from_entry_to_today} , ngay nhap: {entry_date}, ngay hien tai: {today}") 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 - print(f"lai du phong la : {additional_penalty_to_today}") + 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 @@ -259,12 +404,16 @@ def allocate_payment_to_schedules(product_id): 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 : + else: sch.ovd_days = days_for_trace - print(f"Lai la : {penalty_to_this_entry + additional_penalty_to_today} = {sch.penalty_amount}") + # 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", @@ -275,55 +424,41 @@ def allocate_payment_to_schedules(product_id): "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), }) - sch.entry = sch_entry_list # Lưu lại list + sch.entry = sch_entry_list - if sch.amount_remain <= 0 and sch.penalty_remain <= 0: + # Đóng lịch: chỉ khi status hiện tại là 1 + if sch.status_id == 1 and sch.amount_remain <= 0 and sch.penalty_remain <= 0: sch.status = paid_payment_status - + sch.save() - if sch.id not in updated_schedules: updated_schedules.append(sch.id) + 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) + "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_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 (Giữ nguyên gốc) + # Cập nhật Transaction và Transaction_Detail if total_principal_allocated > 0 or total_penalty_allocated > 0: - # Cập nhật tiền gốc đã nhận - txn_detail.amount_received = F('amount_received') + total_principal_allocated - txn_detail.amount_remaining = F('amount_remaining') - total_principal_allocated - txn_detail.save() - - txn.amount_received = F('amount_received') + total_principal_allocated - txn.amount_remain = F('amount_remain') - total_principal_allocated - txn.save() - - # QUAN TRỌNG: Kiểm tra để đóng Status - txn_detail.refresh_from_db() - txn.refresh_from_db() - - # Nếu gốc đã hết (amount_remaining <= 0) - # VÀ không còn kỳ lịch thanh toán nào chưa hoàn thành (đã sạch nợ lãi) - has_pending_sch = Payment_Schedule.objects.filter(txn_detail=txn_detail, status__id=1).exists() - - if txn_detail.amount_remaining <= 0 and not has_pending_sch: - if paid_txn_status: - txn_detail.status = paid_txn_status - txn_detail.save() - txn.status = paid_txn_status - txn.save() + recalc_txn_from_schedules(txn, all_txn_details, paid_txn_status) return {"status": "success", "updated_schedules": updated_schedules} @@ -331,6 +466,8 @@ def allocate_payment_to_schedules(product_id): import traceback print(traceback.format_exc()) return {"status": "error", "errors": [str(exc)]} + + # ========================================================================================== def allocate_penalty_reduction(product_id): """ @@ -348,7 +485,7 @@ def allocate_penalty_reduction(product_id): 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") @@ -356,18 +493,8 @@ def allocate_penalty_reduction(product_id): txn = booked.transaction - txn_detail = None - try: - current = Transaction_Current.objects.get(transaction=txn) - txn_detail = current.detail - except (Transaction_Current.DoesNotExist, AttributeError): - txn_detail = Transaction_Detail.objects.filter( - transaction=txn - ).order_by('-create_time').first() - - if not txn_detail: - errors.append(f"Product {product_id}: Không tìm thấy Transaction_Detail") - return {"status": "error", "errors": errors} + # Lấy 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, @@ -383,10 +510,11 @@ def allocate_penalty_reduction(product_id): "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=txn_detail, - status__id=1 # Chỉ xử lý các lịch chưa thanh toán - ).order_by('cycle', 'from_date') + txn_detail__in=all_txn_details, + status__id=1 + ).order_by('txn_detail__create_time', 'cycle', 'from_date') if not schedules.exists(): return { @@ -395,22 +523,28 @@ def allocate_penalty_reduction(product_id): "updated_schedules": [], "updated_entries": [], "errors": [] } + paid_payment_status = Payment_Status.objects.filter(id=2).first() + paid_txn_status = Transaction_Status.objects.filter(id=2).first() + 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)) - if current_penalty_remain <= 0: continue + 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 @@ -428,10 +562,12 @@ 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: + # Đó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 @@ -443,8 +579,10 @@ def allocate_penalty_reduction(product_id): 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") }) @@ -456,20 +594,27 @@ 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 + # 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_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") + 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)}") @@ -490,7 +635,12 @@ 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. + + 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: @@ -501,142 +651,56 @@ def reset_product_state_before_allocation(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() + all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - if not txn_detail: - print(f"Reset: Không tìm thấy txn_detail cho product {product_id}") + 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 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' + # 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 ) - 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' - ]) + unpaid_status = Payment_Status.objects.get(id=1) + reset_schedules_to_pristine(all_schedules, unpaid_status) - # ================================================================= - # 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 + # Bước 2: Reset allocation của tất cả entry CR + reset_cr_entries_allocation(product_id) - # 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 ===== + # 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']) - txn_detail.refresh_from_db() - # Đặt lại status về unpaid nếu còn nợ - if txn_detail.amount_remaining > 0: + # 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: + except Exception: 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)) + txn.amount_received = Decimal('0') + txn.amount_remain = Decimal(str(txn.sale_price or 0)) + txn.save(update_fields=['amount_received', 'amount_remain']) - # 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 + # Transaction không có field status — không cần reset - # ===== 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}") + 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')}] Lỗi khi reset product state for product_id={product_id}: {str(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()) @@ -644,12 +708,15 @@ def reset_product_state_before_allocation(product_id): 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. + 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}") + 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ợ của sản phẩm về gốc + # 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ả) @@ -658,12 +725,18 @@ def background_allocate(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( + 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')}] Background allocation error for product_id={product_id}: {str(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()) @@ -701,7 +774,9 @@ def account_entry(request): if product_id: def run_allocation(): - thread = threading.Thread(target=background_allocate, args=(product_id,), daemon=True) + thread = threading.Thread( + target=background_allocate, args=(product_id,), daemon=True + ) thread.start() transaction.on_commit(run_allocation) @@ -738,19 +813,19 @@ def account_multi_entry(request): 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..." + "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 product_id in product_ids: + for pid in product_ids: thread = threading.Thread( - target=background_allocate, - args=(product_id,), - daemon=True + target=background_allocate, args=(pid,), daemon=True ) thread.start() - transaction.on_commit(run_allocations) return response @@ -762,9 +837,13 @@ def account_multi_entry(request): @api_view(['POST']) def delete_entry(request): - """Xóa bút toán - reset sạch entry = [], lưu hết trước, xóa entry sau, đặt lại txn/txndetail, rồi phân bổ lại""" + """ + 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) @@ -773,9 +852,9 @@ def delete_entry(request): 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) + 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, @@ -790,97 +869,42 @@ def delete_entry(request): } 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) + 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) - - allocation_detail = entry.allocation_detail or [] - schedules_reversed = [] + return Response( + {'error': 'Bút toán không gắn với product nào'}, status=400 + ) # ================================================================= - # Bước 1: Reset các lịch bị ảnh hưởng theo công thức & lưu + # Bước 1: Reset tất cả lịch của transaction về pristine # ================================================================= - 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') + 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 + ) - if not schedule_id or allocated_amount <= 0: - continue + txn = booked.transaction + all_txn_details = Transaction_Detail.objects.filter(transaction=txn) - try: - schedule = Payment_Schedule.objects.select_for_update().get(id=schedule_id) - - 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))) - - 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 - 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 - - # Reset theo công thức - 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 = Payment_Status.objects.get(id=1) - - schedule.save(update_fields=[ - 'entry', 'paid_amount', 'amount_remain', 'remain_amount', - 'penalty_paid', 'penalty_reduce', 'penalty_remain', 'ovd_days', 'status','penalty_amount' - ]) - - 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 - - # ================================================================= - # Bước 2: Reset allocation của tất cả entry CR của sản phẩm & lưu - # ================================================================= - all_cr_entries = Internal_Entry.objects.filter( - product_id=product_id, - type__code='CR' + all_schedules = Payment_Schedule.objects.select_for_update().filter( + txn_detail__in=all_txn_details ) - 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' - ]) + unpaid_status = Payment_Status.objects.get(id=1) + reset_schedules_to_pristine(all_schedules, unpaid_status) # ================================================================= - # Bước 3: Hoàn tác số dư tài khoản (lưu trước khi xóa entry) + # 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) @@ -891,105 +915,36 @@ def delete_entry(request): account.save(update_fields=['balance']) # ================================================================= - # Bước 4: XÓA ENTRY (sau khi reset và lưu hết) + # Bước 4: Xóa entry # ================================================================= entry.delete() # ================================================================= - # Bước 5: ĐẶT LẠI Transaction & Transaction_Detail TRƯỚC KHI PHÂN BỔ LẠI + # Bước 5: Đặt lại Transaction & Transaction_Detail về unpaid + # (amount_received = 0 vì tất cả lịch đã reset về pristine) # ================================================================= - txn_detail_updated = False - txn_updated = False + 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 entry.product: - try: - booked = Product_Booked.objects.filter(product=entry.product).first() - if booked and booked.transaction: - txn = booked.transaction + 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 - # Tính lại Transaction_Detail hiện tại TRƯỚC - 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() + txn.amount_received = Decimal('0') + txn.amount_remain = Decimal(str(txn.sale_price or 0)) + txn.save(update_fields=['amount_received', 'amount_remain']) - 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') - 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 - - 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 - - # ====== 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 = 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() - 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 đặt lại Transaction trước phân bổ: {str(e)}") + # 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 (sẽ tự tính lại txn/txndetail đúng) + # 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: @@ -997,6 +952,7 @@ def delete_entry(request): 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() @@ -1004,20 +960,17 @@ def delete_entry(request): return Response({ 'success': True, - 'message': 'Đã xóa bút toán, reset sạch entry = [], lưu hết trước, xóa entry sau, đặt lại txn/txndetail trước phân bổ, đang phân bổ lại toàn bộ...', + '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, - 'reversed': { - 'schedules_count': len(schedules_reversed), - 'schedules': schedules_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 + 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/workflow_engine.py b/app/workflow_engine.py index 0c579355..4a10aeb6 100644 --- a/app/workflow_engine.py +++ b/app/workflow_engine.py @@ -5,12 +5,12 @@ from app.workflow_utils import resolve_value @transaction.atomic def execute_step(step: StepAction, context: dict): - print(f"\n>>> EXECUTING STEP: {step.step_code} (Order: {step.order})") + #print(f"\n>>> EXECUTING STEP: {step.step_code} (Order: {step.order})") # Evaluate rules first for rule in step.rules.filter(is_active=True): if not evaluate_rule(rule, context): - print(f"Step {step.step_code} skipped due to rule failure.") + #print(f"Step {step.step_code} skipped due to rule failure.") return {"step": step.step_code, "skipped": True, "reason": "rule_failed"} results = [] @@ -21,10 +21,10 @@ def execute_step(step: StepAction, context: dict): action_type = action.get("type") params = action.get("params", {}) - print(f" - Action Type: {action_type}") + #print(f" - Action Type: {action_type}") if action_type not in ACTION_REGISTRY: - print(f" - ERROR: Action type '{action_type}' not registered!") + #print(f" - ERROR: Action type '{action_type}' not registered!") continue try: @@ -39,7 +39,7 @@ def execute_step(step: StepAction, context: dict): # Lưu output cuối cùng vào context context["last_result"] = output except Exception as e: - print(f" - ERROR in action {action_type}: {str(e)}") + #print(f" - ERROR in action {action_type}: {str(e)}") # Raise để transaction.atomic rollback nếu cần, hoặc xử lý tùy ý raise e @@ -52,7 +52,7 @@ def evaluate_rule(rule: Rule, context: dict): right = resolve_value(condition.get("right"), context) op = condition.get("operator", "==") - print(f" Evaluating Rule: {left} {op} {right}") + #print(f" Evaluating Rule: {left} {op} {right}") if op == "IN" and left not in right: return False if op == "==" and left != right: return False @@ -64,21 +64,21 @@ def evaluate_rule(rule: Rule, context: dict): def run_workflow(workflow_code: str, trigger: str, context: dict): - print(f"\n================ START WORKFLOW: {workflow_code} ================") - print(f"Trigger: {trigger} | Initial Context: {context}") + #print(f"\n================ START WORKFLOW: {workflow_code} ================") + #print(f"Trigger: {trigger} | Initial Context: {context}") workflow = Workflow.objects.filter(code=workflow_code, is_active=True).first() if not workflow: - print(f"Workflow '{workflow_code}' not found or inactive.") + #print(f"Workflow '{workflow_code}' not found or inactive.") raise Exception(f"Workflow '{workflow_code}' not found") steps = workflow.steps.filter(trigger_event=trigger, is_active=True).order_by("order") - print(f"Found {steps.count()} active steps.") + #print(f"Found {steps.count()} active steps.") outputs = [] for step in steps: res = execute_step(step, context) outputs.append(res) - print(f"================ FINISH WORKFLOW: {workflow_code} ================\n") + #print(f"================ FINISH WORKFLOW: {workflow_code} ================\n") return outputs \ No newline at end of file diff --git a/static/files/20260210100326-entry.xlsx b/static/files/20260210100326-entry.xlsx new file mode 100644 index 00000000..fb690e48 Binary files /dev/null and b/static/files/20260210100326-entry.xlsx differ