From f94611f9734436a1bf5809a678271ea4af7a7386 Mon Sep 17 00:00:00 2001 From: anhduy-tech Date: Wed, 11 Feb 2026 01:38:57 +0700 Subject: [PATCH] changes --- api/__pycache__/settings.cpython-313.pyc | Bin 3451 -> 3446 bytes app/__pycache__/payment.cpython-313.pyc | Bin 45698 -> 41896 bytes .../workflow_engine.cpython-313.pyc | Bin 4362 -> 3249 bytes app/payment.py | 777 ++++++++---------- app/workflow_engine.py | 22 +- static/files/20260210100326-entry.xlsx | Bin 0 -> 7658 bytes 6 files changed, 376 insertions(+), 423 deletions(-) create mode 100644 static/files/20260210100326-entry.xlsx diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index 3841ed315bef63d23a57b30b273c84438b26855f..641c13af83f326481420368f4b70f77875839f92 100644 GIT binary patch delta 251 zcmew@^-YTRGcPX}0}wo@?9O~Sk$0IUXAy5OXAxg8mp(%|B4yaHzSRPC&fJsFIMtznd(O{(_ zv0&vQaiGx>Kr9KwQo$-k(!r`lGQq;ZYQgF;!h8(D8ph1Qn!#E?o!WZLKr>~5ayr4f zF~Y%mCQSMuXXxuO0mbFg4KxiCH8!^~)-W=GRCooEH ee#8EZnbB?XWo~yyzRA)&i9oWJ=NF3$(6<14O*<6; delta 256 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 Qr;OH!iPvNp6b*p?2hq}TF#rGn delta 19922 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 diff --git a/app/__pycache__/workflow_engine.cpython-313.pyc b/app/__pycache__/workflow_engine.cpython-313.pyc index 9e91cb75a99cbd62c16bc40fda85bc50ffc43b8e..68d6b54596cfc74ac21a753c10a2a6c428a9be95 100644 GIT binary patch delta 1378 zcmYjR&2Jl35TE`2vg0I9(=@8^+I2BYz!Z`|YnoJTq!xq{ByU$(_+UBq+8Dxac)MT8`0<}_kMI7K-@u5m6e*)wR5`qJ0PgLc=(Wsorg?Vdl(y&LhO_x|ToztA4dC&0i9X;8q61Z# zfu}fIu*I?v@Fo<&027`APpE6`V78E_)A#r+m!co?xl$Z(ge3BH4n0Ii!~h)(AURSs z6Ni>c6Way(3@r0wtK!J@$>fxmk=sOM{RMz0pq@?awQj;Up*~4>g;OsbOR%GN zt!FD`K8a^S?Y6C!5MF-^;yWd`y~H_!t(Ao$u3B8A^@2vT751*V^lNdE&P%zImOxlGBGrB0ZS_O99Vx^O`deXOrRWVQr)fdx zZZ}$fo8FaH&sj!HCkFEoNq@^F$C;X9$NFB-*y;r#VfT)Vk*N{xZo6cf=HJbSELLS2{jzL(Yvjy2w5wYN{Yg7{ zJ>p;8xIEzVD-pLEap$jELZr8Ruic9?N>~$<912U0FFMM-_J~0i7~vTd5$|sYEWc*F z$WsiMPsH=61Ye@qgFj~HzwmGSzt1jwGyN<5NMDs$_9y5AJ(!b_5vT5H?B&m)j@x+A zUxZ_HsLtpP*lZN}szUD@rPCRJqdBt$2PVK47lovYUF=nyy?rA~|53BL!=bZ;&S^GW zi*iEqL>xaU9zVRQC)e@V(W-4GqvI(!fK&7^^(24nAEscoi94Y?h+ioii$O*QSIz57o zoR4)j0YRk3^v32cvG5lJ#6)4KWSQwNG7-;&x-R~D3(|aq7>f;QA4mEGGQOZ4x|V1W%!T+K+pFC*-PZ-Fc^ XYaB#9|E}N0fm|i8(VLUIB18TM@Npa6 delta 2457 zcmZ`*O>7&-6`uY7BdH%svHT-z#K=(=vMJfJYm1hM$_{1Ai6nfu2VV=ga8K=}Z4SH%+m+ZMCgF~yy;3#0(vj#ZpBUYvAYS+d3OYeMNo(5X3!ITmb0cUN6_17X z5-JZvv*?=ZDj`?+aHg+|k4io54Cq;CzfDUpDWW ztDCl2@uiMUL+|p14N|piUx6qarPX@H^o2St*8Mv-kU1AGylMh&LwK6=V4UM#hH{&(3gvtH_4OMmpn zcQ^iYlT0VA*PbhFr8@TP8fy>GT-Z~e{P5z3`N@OtxjGv3O7 z%RQR?Mr_^ndVk!G&F=LkKf3mxXA-CM{kuWS`ZG6~Q`yI=n!6x8?mLm25FSqmK$F)% zxEh5M1sDPFIPN8Jg89m=il3e?O27@4Arv?`GjJAzorgITBXcMilFLZ_Q*uEt_{<8N zIIvtfpSc4RM8RmCN|cC%>KsT^cS-6WqdFSZseokR^T0!IM{nV)ju(n7>oGm1<=4onM@D*x?zU$@C`m{EtaI{Mvt>D z2JPsKgtD(j#;L-D`%fv^cbbwX!|f~(jj!DS`Q}jY@lNelc7M`5+srrLXfdr*ts`Dy zsx4>sw7C18+$hq430k`cLvt9@p`C0Lh$1k+6&R561@`4bUxem#R-O;=TyndVen~LI zDK@XTW6J1N$TZ?dD$u(?eklqa|6my%sd6ASnF5!OQO|?>pFh+_sK6M4p+clC3Tel+ za0o0u5;0cb^F5Hsu}cs*e&9N|6@&s0*Uk`No>)wVj#qAncxy?3_x8h>xwn>X0X+5KLnl6zCaNfuB7R*Mu zhOgEw+q`RM@Up(m`aH!ELT@fdy;UdguGQ*y0MiCKn6_5mv?{nx0H!EZY?*0Ci%0xJYjl(e z{U6{<_1o{5t9FCX3&a=JsstcyB%p&Zz8nB!>GnNeScN=nLUE0}3Y#vI45cr_*3&r= z@|_ojlUetV>c}EJ3x14jTBUu#HDFzVr|}%N`{)UU#qn0Wx$R|_JoRRqe;ZgI&F`%5 z{M0*nu641cd+KzXpLyDK#AUUSQR&zTSKD+$M zWp`$0eD|cMPPF;6dx>thqNPs!OGbj~#nR0WnssmHrl-E$=JP*jz4sT%l>4#fj0I%{ i$MzH?MrfM9M`Vi9K^5}!d-V!nMwTqO=VSMHD)}#46cKU& 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 0000000000000000000000000000000000000000..fb690e4899f708639a3c4eb6128108a08154536c GIT binary patch literal 7658 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 literal 0 HcmV?d00001