Files
api/app/payment.py
anhduy-tech 39aea8784e changes
2026-02-24 11:09:09 +07:00

1041 lines
44 KiB
Python

from app.models import *
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.db import transaction
from datetime import datetime, date
from decimal import Decimal
from django.db.models import F
import threading
import json
# ==========================================================================================
# HELPER FUNCTIONS
# ==========================================================================================
def get_latest_payment_date(schedule):
"""Lấy ngày nộp gần nhất từ entry (type='PAYMENT')"""
if not schedule.entry:
return None
entry_data = schedule.entry
if isinstance(entry_data, str):
try:
entry_data = json.loads(entry_data)
except json.JSONDecodeError:
return None
if not isinstance(entry_data, list):
return None
payment_dates = []
for item in entry_data:
if item.get('type') == 'PAYMENT' and item.get('date'):
try:
dt = datetime.strptime(item['date'], "%Y-%m-%d").date()
payment_dates.append(dt)
except:
pass
if payment_dates:
return max(payment_dates)
return None
# ==========================================================================================
def getcode(code, Model):
try:
obj = Model.objects.latest('id')
val = 1 if obj is None else obj.id + 1
except Exception:
val = 1
length = len(str(val))
text = '0' * (6 - length)
return f"{code}{text}{val}"
# ==========================================================================================
def account_entry_api(code, amount, content, type, category, userid, ref=None, product=None, customer=None, date=None):
try:
user = User.objects.get(id=userid)
entry_type = Entry_Type.objects.get(code=type)
entry_category = Entry_Category.objects.get(id=category)
system_date = date if date else datetime.now().strftime("%Y-%m-%d")
amount = float(amount)
with transaction.atomic():
account = Internal_Account.objects.select_for_update().get(code=code)
start_balance = account.balance or 0
if entry_type.code == 'DR' and start_balance < amount:
return {'error': 'Số dư không đủ để thực hiện giao dịch.'}, None
if entry_type.code == 'CR':
account.balance += amount
else:
account.balance -= amount
account.save()
account.refresh_from_db()
new_balance = account.balance
entry = Internal_Entry.objects.create(
category=entry_category,
content=content,
amount=amount,
inputer=user,
approver=user,
type=entry_type,
balance_before=start_balance,
balance_after=new_balance,
account=account,
date=system_date,
ref=ref,
product=None if product is None else Product.objects.get(id=product),
customer=None if customer is None else Customer.objects.get(id=customer),
allocation_amount=Decimal('0'),
allocation_remain=Decimal(str(amount)) if entry_type.code == 'CR' else Decimal('0'),
allocation_detail=[]
)
text = 'id,account__currency__code,ref,balance_before,balance_after,code,account,account__code,account__branch__name,account__type__name,date,amount,content,inputer,inputer__fullname,approver,approver__fullname,create_time,update_time,type,type__code,type__name,allocation_amount,allocation_remain'
fields = text.split(',')
response_data = Internal_Entry.objects.filter(id=entry.id).values(*fields).first()
return response_data, entry
except User.DoesNotExist:
return {'error': f"Người dùng với ID {userid} không tồn tại."}, None
except Internal_Account.DoesNotExist:
return {'error': f"Tài khoản nội bộ với mã '{code}' không tồn tại."}, None
except Entry_Type.DoesNotExist:
return {'error': f"Loại bút toán với mã '{type}' không tồn tại."}, None
except Entry_Category.DoesNotExist:
return {'error': f"Danh mục bút toán với ID '{category}' không tồn tại."}, None
except Exception as e:
return {'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"}, None
# ==========================================================================================
def get_allocation_rule():
return 'principal-fee'
# ==========================================================================================
def safe_json_serialize(obj):
"""Serialize an toàn cho JSONField"""
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, dict):
return {k: safe_json_serialize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [safe_json_serialize(item) for item in obj]
return obj
# ==========================================================================================
def get_original_amount_remain_from_trace(schedule):
"""
Lấy amount_remain gốc của lịch (trước khi có bất kỳ phân bổ nào) từ trace.
Quy tắc:
- Trace PAYMENT được ghi theo thứ tự thời gian, mỗi record có field
'amount_remain_before' = số tiền còn lại của lịch TRƯỚC KHI entry đó phân bổ vào.
- Trace đầu tiên (sort by date asc) → amount_remain_before = trạng thái gốc ban đầu.
- Nếu trace rỗng (lịch chưa từng được phân bổ) → trả về None,
caller giữ nguyên amount_remain hiện tại.
"""
entry_list = schedule.entry
if not entry_list:
return None
payment_traces = [
e for e in entry_list
if e.get('type') == 'PAYMENT' and e.get('amount_remain_before') is not None
]
if not payment_traces:
return None
# Lấy trace PAYMENT sớm nhất theo date
payment_traces.sort(key=lambda e: e.get('date', ''))
first_trace = payment_traces[0]
return Decimal(str(first_trace['amount_remain_before']))
# ==========================================================================================
def reset_schedules_to_pristine(all_schedules, unpaid_status):
"""
Đưa tất cả lịch về trạng thái trước khi có bất kỳ phân bổ nào.
amount_remain gốc được đọc từ trace đầu tiên (field 'amount_remain_before').
Nếu lịch chưa có trace → chưa từng phân bổ → giữ nguyên amount_remain hiện tại.
Xóa sạch trace (entry=[]) vì toàn bộ allocation sẽ được tính lại từ đầu.
"""
for schedule in all_schedules:
original_amount_remain = get_original_amount_remain_from_trace(schedule)
if original_amount_remain is None:
# Lịch chưa từng được phân bổ → amount_remain hiện tại chính là gốc
original_amount_remain = Decimal(str(schedule.amount_remain or 0))
schedule.entry = []
schedule.amount_remain = original_amount_remain
schedule.remain_amount = original_amount_remain
# paid_amount = tổng amount của lịch - phần còn lại gốc
schedule.paid_amount = max(
Decimal('0'),
Decimal(str(schedule.amount or 0)) - original_amount_remain
)
schedule.penalty_paid = Decimal('0')
schedule.penalty_reduce = Decimal('0')
schedule.penalty_amount = Decimal('0')
schedule.penalty_remain = Decimal('0')
schedule.ovd_days = 0
schedule.status = unpaid_status
schedule.save(update_fields=[
'entry', 'paid_amount', 'amount_remain', 'remain_amount',
'penalty_paid', 'penalty_reduce', 'penalty_remain',
'ovd_days', 'status', 'penalty_amount'
])
# ==========================================================================================
def reset_cr_entries_allocation(product_id, exclude_entry_id=None):
"""
Reset allocation của tất cả entry CR của sản phẩm về trạng thái chưa phân bổ.
Nếu exclude_entry_id được truyền (trường hợp xóa entry), bỏ qua entry đó.
"""
qs = Internal_Entry.objects.filter(product_id=product_id, type__code='CR')
if exclude_entry_id:
qs = qs.exclude(id=exclude_entry_id)
for e in qs:
e.allocation_detail = []
e.allocation_amount = Decimal('0')
e.allocation_remain = Decimal(str(e.amount))
e.save(update_fields=['allocation_detail', 'allocation_amount', 'allocation_remain'])
# ==========================================================================================
def close_paid_schedules(txn):
"""
Quét toàn bộ Payment_Schedule thuộc txn_detail và đóng (status=2) những lịch
đã được thanh toán đầy đủ.
Điều kiện đóng một lịch:
- status_id == 1 (đang mở/chưa đóng)
- amount_remain <= 0 (đã trả hết gốc)
- penalty_remain <= 0 (đã trả hết phạt)
Hàm không raise exception — lỗi từng lịch được bỏ qua và in ra log,
các lịch còn lại vẫn được xử lý bình thường.
Returns:
list[int]: danh sách id các lịch vừa được đóng.
"""
all_txn_details = Transaction_Detail.objects.filter(transaction=txn)
closed_ids = []
try:
paid_status = Payment_Status.objects.get(id=2)
except Payment_Status.DoesNotExist:
print("[close_paid_schedules] Không tìm thấy Payment_Status id=2, bỏ qua.")
return closed_ids
for txn_detail in all_txn_details:
schedules = Payment_Schedule.objects.filter(
txn_detail=txn_detail,
)
for sch in schedules:
try:
if sch.remain_amount <= 0:
sch.status = paid_status
sch.save(update_fields=['status'])
closed_ids.append(sch.id)
except Exception as e:
print(f"[close_paid_schedules] Lỗi khi đóng schedule {sch.id}: {e}")
print(f"[close_paid_schedules] Đã đóng {len(closed_ids)} lịch")
return closed_ids
def recalc_txn_from_schedules(txn, all_txn_details, paid_txn_status):
"""
Tính lại Transaction và tất cả Transaction_Detail từ trạng thái hiện tại của các lịch.
Quy tắc:
- Chỉ cập nhật status của Transaction_Detail thành paid (2) nếu:
- Đây là detail HIỆN TẠI (current)
- status_id HIỆN TẠI là 1 (unpaid/pending)
- amount_remaining <= 0
- Không còn lịch thanh toán nào đang pending (status=1)
- Nếu status đã là 3, 6 hoặc các trạng thái khác → giữ nguyên, không thay đổi
- txn_total_received chỉ cộng từ các detail đã hoàn thành (status=2)
HOẶC detail current đang đủ điều kiện đóng
"""
txn_total_received = Decimal('0')
# Lấy current detail một lần để tránh query lặp
current = Transaction_Current.objects.filter(transaction=txn).first()
current_detail = current.detail if current else None
for detail in all_txn_details:
detail.refresh_from_db()
# Tính tổng đã trả từ các lịch của detail này
detail_schedules = Payment_Schedule.objects.filter(txn_detail=detail)
detail_paid = sum(Decimal(str(s.paid_amount or 0)) for s in detail_schedules)
detail.amount_received = detail_paid
detail.amount_remaining = Decimal(str(detail.amount or 0)) - detail_paid
detail.save(update_fields=['amount_received', 'amount_remaining'])
# ====================================================
# Chỉ xử lý đóng status cho detail HIỆN TẠI (current)
# ====================================================
if detail == current_detail:
txn_total_received += detail_paid
has_pending = Payment_Schedule.objects.filter(
txn_detail=detail,
status__id=1
).exists()
print(f"status current: {detail.status_id}")
if (
detail.amount_remaining <= 0
and not has_pending
and detail.status_id == 1
):
if paid_txn_status:
detail.status = paid_txn_status # status=2 (paid)
detail.save(update_fields=['status'])
print(f"[recalc] Đóng Transaction_Detail {detail.id} → status=2 (paid)")
# Cập nhật Transaction
txn.amount_received = txn_total_received
txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn_total_received
txn.save(update_fields=['amount_received', 'amount_remain'])
txn.refresh_from_db()
# ==========================================================================================
# Mapping phase_id → DAILY_PENALTY_RATE
# Phase 1, 2, 4: 0.03%/ngày | Các phase khác: 0.05%/ngày
PENALTY_RATE_LOW_PHASES = [1, 2, 4]
PENALTY_RATE_LOW = Decimal('0.0003') # 0.03%
PENALTY_RATE_HIGH = Decimal('0.0005') # 0.05%
def get_penalty_rate_for_schedule(schedule):
"""
Trả về DAILY_PENALTY_RATE tương ứng với phase của Transaction_Detail gắn với lịch.
- Phase 1, 2, 4 → 0.03%/ngày (0.0003)
- Các phase khác → 0.05%/ngày (0.0005)
"""
try:
phase_id = schedule.txn_detail.phase_id
print(f"phase_id: {phase_id}")
if phase_id in PENALTY_RATE_LOW_PHASES:
return PENALTY_RATE_LOW
return PENALTY_RATE_HIGH
except Exception:
# Nếu không lấy được phase thì dùng mức cao (an toàn hơn)
return PENALTY_RATE_HIGH
# ==========================================================================================
def allocate_payment_to_schedules(product_id):
if not product_id:
return {"status": "no_product", "message": "Không có product_id"}
updated_schedules = []
paid_payment_status = Payment_Status.objects.filter(id=2).first()
paid_txn_status = Transaction_Status.objects.filter(id=2).first()
today = datetime.now().date()
with transaction.atomic():
try:
product = Product.objects.get(id=product_id)
booked = Product_Booked.objects.filter(product=product).first()
if not booked or not booked.transaction:
return {"status": "error", "errors": ["Không tìm thấy Transaction"]}
txn = booked.transaction
# Lấy TẤT CẢ detail của transaction
all_txn_details = Transaction_Detail.objects.filter(transaction=txn)
# Lấy các bút toán CR còn dư tiền (trừ tài khoản miễn lãi id=5)
entries_with_remain = Internal_Entry.objects.select_for_update().filter(
product=product,
type__code='CR',
allocation_remain__gt=0
).exclude(account__id=5).order_by('date', 'create_time')
if not entries_with_remain.exists():
return {"status": "success", "message": "Không có tiền để phân bổ"}
# Lấy lịch của TẤT CẢ detail, sắp xếp theo detail cũ → mới, rồi cycle
schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail__in=all_txn_details,
).order_by('txn_detail__create_time', 'cycle', 'from_date')
total_principal_allocated = Decimal('0')
total_penalty_allocated = Decimal('0')
for entry in entries_with_remain:
entry_date = entry.date
if isinstance(entry_date, str):
entry_date = datetime.strptime(entry_date, "%Y-%m-%d").date()
remaining = Decimal(str(entry.allocation_remain))
if remaining <= 0:
continue
entry_allocation_detail = entry.allocation_detail or []
entry_principal_allocated = Decimal('0')
entry_penalty_allocated = Decimal('0')
for sch in schedules:
if remaining <= 0:
break
# --- XÁC ĐỊNH LÃI PHẠT THEO PHASE ---
DAILY_PENALTY_RATE = get_penalty_rate_for_schedule(sch)
print(f"DAILY_PENALTY_RATE: {DAILY_PENALTY_RATE}")
current_amount_remain = Decimal(str(sch.amount_remain or 0))
# --- BƯỚC 1: LẤY LÃI TÍCH LŨY TỪ TRACE ---
last_entry_date = None
accumulated_penalty_to_last = Decimal('0')
if sch.entry:
for e in sch.entry:
if e.get('type') == 'PAYMENT':
e_date = datetime.strptime(e['date'], "%Y-%m-%d").date()
if e_date <= entry_date:
if e.get('penalty_to_this_entry') is not None:
accumulated_penalty_to_last = Decimal(str(e['penalty_to_this_entry']))
if not last_entry_date or e_date > last_entry_date:
last_entry_date = e_date
# --- BƯỚC 2: TÍNH LÃI PHÁT SINH MỚI ---
penalty_added_to_entry = Decimal('0')
days_for_trace = 0
if current_amount_remain > 0:
if last_entry_date:
days_between = max(0, (entry_date - last_entry_date).days)
penalty_added_to_entry = current_amount_remain * Decimal(days_between) * DAILY_PENALTY_RATE
days_for_trace = days_between
else:
days_overdue_to_entry = max(0, (entry_date - sch.to_date).days)
penalty_added_to_entry = current_amount_remain * Decimal(days_overdue_to_entry) * DAILY_PENALTY_RATE
days_for_trace = days_overdue_to_entry
penalty_to_this_entry = accumulated_penalty_to_last + penalty_added_to_entry
penalty_to_pay_now = max(
Decimal('0'),
penalty_to_this_entry - Decimal(str(sch.penalty_paid or 0))
)
# --- BƯỚC 3: PHÂN BỔ TIỀN ---
to_principal = min(remaining, current_amount_remain)
remaining -= to_principal
amount_remain_after = current_amount_remain - to_principal
to_penalty = min(remaining, penalty_to_pay_now)
remaining -= to_penalty
allocated_here = to_principal + to_penalty
if allocated_here <= 0:
continue
entry_principal_allocated += to_principal
entry_penalty_allocated += to_penalty
# --- BƯỚC 4: LÃI DỰ PHÒNG ĐẾN NAY ---
days_from_entry_to_today = max(0, (today - entry_date).days)
additional_penalty_to_today = Decimal('0')
if amount_remain_after > 0:
additional_penalty_to_today = (
amount_remain_after * Decimal(days_from_entry_to_today) * DAILY_PENALTY_RATE
)
# --- CẬP NHẬT DỮ LIỆU ---
sch.paid_amount = Decimal(str(sch.paid_amount or 0)) + to_principal
sch.penalty_paid = Decimal(str(sch.penalty_paid or 0)) + to_penalty
sch.amount_remain = amount_remain_after
sch.penalty_amount = penalty_to_this_entry + additional_penalty_to_today
sch.penalty_remain = max(Decimal('0'), sch.penalty_amount - sch.penalty_paid)
sch.remain_amount = sch.amount_remain + sch.penalty_remain
if amount_remain_after > 0:
sch.ovd_days = max(0, (today - sch.to_date).days)
else:
sch.ovd_days = days_for_trace
# Ghi Trace
# Lưu ý: amount_remain_before là trạng thái của lịch TRƯỚC KHI entry này
# phân bổ vào. Trace đầu tiên (sớm nhất theo date) sẽ chứa giá trị gốc
# ban đầu của lịch, dùng để restore khi reset toàn bộ phân bổ.
sch_entry_list = sch.entry or []
sch_entry_list.append({
"type": "PAYMENT",
"code": entry.code,
"date": entry_date.strftime("%Y-%m-%d"),
"amount": float(allocated_here),
"principal": float(to_principal),
"penalty": float(to_penalty),
"penalty_added_to_entry": float(penalty_added_to_entry),
"penalty_to_this_entry": float(penalty_to_this_entry),
"amount_remain_before": float(current_amount_remain),
"amount_remain_after_allocation": float(amount_remain_after),
"DAILY_PENALTY_RATE": float(DAILY_PENALTY_RATE)
})
sch.entry = sch_entry_list
sch.save()
if sch.id not in updated_schedules:
updated_schedules.append(sch.id)
entry_allocation_detail.append({
"schedule_id": sch.id,
"amount": float(allocated_here),
"principal": float(to_principal),
"penalty": float(to_penalty)
})
# Cập nhật Entry nguồn
entry.allocation_amount = (
(entry.allocation_amount or Decimal('0')) +
entry_principal_allocated + entry_penalty_allocated
)
entry.allocation_remain = remaining
entry.allocation_detail = entry_allocation_detail
entry.save()
total_principal_allocated += entry_principal_allocated
total_penalty_allocated += entry_penalty_allocated
# Cập nhật Transaction và Transaction_Detail
if total_principal_allocated > 0 or total_penalty_allocated > 0:
close_paid_schedules(txn)
recalc_txn_from_schedules(txn, all_txn_details, paid_txn_status)
return {"status": "success", "updated_schedules": updated_schedules}
except Exception as exc:
import traceback
print(traceback.format_exc())
return {"status": "error", "errors": [str(exc)]}
# ==========================================================================================
def allocate_penalty_reduction(product_id):
"""
Xử lý miễn giảm tiền phạt cho một sản phẩm cụ thể.
Quét các entry CR từ tài khoản miễn lãi (id=5) có allocation_remain > 0.
Sau khi miễn giảm, kiểm tra và đóng lịch/giao dịch nếu đủ điều kiện.
"""
if not product_id:
return {"status": "no_product", "message": "Không có product_id"}
updated_schedules = []
updated_entries = []
errors = []
with transaction.atomic():
try:
product = Product.objects.get(id=product_id)
booked = Product_Booked.objects.filter(product=product).first()
if not booked or not booked.transaction:
errors.append(f"Product {product_id}: Không tìm thấy Transaction")
return {"status": "error", "errors": errors}
txn = booked.transaction
# Lấy TẤT CẢ detail của transaction
all_txn_details = Transaction_Detail.objects.filter(transaction=txn)
reduction_entries = Internal_Entry.objects.select_for_update().filter(
product=product,
type__code='CR',
account__id=5,
allocation_remain__gt=0
).order_by('date', 'create_time')
if not reduction_entries.exists():
return {
"status": "success",
"message": "Không có entry miễn lãi cần xử lý",
"updated_schedules": [], "updated_entries": [], "errors": []
}
# Lấy TẤT CẢ lịch chưa thanh toán của tất cả detail
schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail__in=all_txn_details,
status__id=1
).order_by('txn_detail__create_time', 'cycle', 'from_date')
if not schedules.exists():
return {
"status": "success",
"message": "Không có lịch thanh toán cần miễn lãi",
"updated_schedules": [], "updated_entries": [], "errors": []
}
paid_payment_status = Payment_Status.objects.filter(id=2).first()
paid_txn_status = Transaction_Status.objects.filter(id=2).first()
for entry in reduction_entries:
remaining_reduce = Decimal(str(entry.allocation_remain))
if remaining_reduce <= 0:
continue
entry_allocation_detail = entry.allocation_detail or []
entry_reduction_allocated = Decimal('0')
for schedule in schedules:
if remaining_reduce <= 0:
break
current_penalty_remain = Decimal(str(schedule.penalty_remain or 0))
if current_penalty_remain <= 0:
continue
to_reduce = min(remaining_reduce, current_penalty_remain)
if to_reduce <= 0:
continue
remaining_reduce -= to_reduce
entry_reduction_allocated += to_reduce
schedule.penalty_reduce = (schedule.penalty_reduce or Decimal('0')) + to_reduce
schedule.penalty_remain -= to_reduce
schedule.remain_amount -= to_reduce
sch_entry_list = schedule.entry or []
sch_entry_list.append({
"type": "REDUCTION",
"code": entry.code,
"date": datetime.now().strftime("%Y-%m-%d"),
"amount": float(to_reduce)
})
schedule.entry = safe_json_serialize(sch_entry_list)
schedule.save(update_fields=[
'penalty_reduce', 'penalty_remain', 'remain_amount', 'entry'
])
# Đóng lịch: chỉ khi status hiện tại là 1
if schedule.status_id == 1 and schedule.remain_amount <= 0 and schedule.amount_remain <= 0:
try:
paid_status = Payment_Status.objects.get(id=2)
schedule.status = paid_status
schedule.save(update_fields=['status'])
except Payment_Status.DoesNotExist:
errors.append("Không tìm thấy Payment_Status id=2")
if schedule.id not in updated_schedules:
updated_schedules.append(schedule.id)
entry_allocation_detail.append({
"schedule_id": schedule.id,
"schedule_code": schedule.code,
"amount": float(to_reduce),
"type": "REDUCTION",
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
entry.allocation_amount = (entry.allocation_amount or Decimal('0')) + entry_reduction_allocated
entry.allocation_remain = remaining_reduce
entry.allocation_detail = entry_allocation_detail
entry.save(update_fields=['allocation_amount', 'allocation_remain', 'allocation_detail'])
if entry.id not in updated_entries:
updated_entries.append(entry.id)
# Kiểm tra đóng transaction sau miễn giảm
# Dùng recalc_txn_from_schedules để đảm bảo nhất quán
# Nhưng KHÔNG ghi đè amount_received/amount_remain vì penalty reduction
# không thay đổi số tiền đã trả, chỉ thay đổi penalty
# → Chỉ kiểm tra đóng nếu đủ điều kiện
try:
txn.refresh_from_db()
for detail in all_txn_details:
detail.refresh_from_db()
# Chỉ đóng detail nếu status_id == 1
if detail.status_id == 1:
has_pending_sch = Payment_Schedule.objects.filter(
txn_detail=detail, status__id=1
).exists()
if detail.amount_remaining <= 0 and not has_pending_sch:
if paid_txn_status:
detail.status = paid_txn_status
detail.save(update_fields=['status'])
# Transaction không có field status — chỉ Transaction_Detail mới có
except Exception as e:
errors.append(f"Lỗi khi đóng transaction sau miễn giảm: {str(e)}")
except Exception as exc:
errors.append(str(exc))
import traceback
print(traceback.format_exc())
return {
"status": "success" if not errors else "partial_failure",
"updated_schedules": updated_schedules,
"updated_entries": updated_entries,
"errors": errors
}
# ==========================================================================================
def reset_product_state_before_allocation(product_id):
"""
Reset toàn bộ trạng thái công nợ của sản phẩm về ban đầu TRƯỚC KHI chạy phân bổ.
Nguồn sự thật: sch.amount (số tiền gốc bất biến của mỗi lịch).
Sau khi reset:
- Tất cả lịch: amount_remain = sch.amount, paid = 0, penalty = 0, trace = []
- Tất cả entry CR: allocation_remain = entry.amount, allocation_amount = 0, detail = []
- Transaction & Detail: tính lại từ trạng thái lịch (= 0 đã trả)
"""
with transaction.atomic():
try:
product = Product.objects.get(id=product_id)
booked = Product_Booked.objects.filter(product=product).first()
if not (booked and booked.transaction):
print(f"Reset: Không tìm thấy transaction cho product {product_id}")
return
txn = booked.transaction
all_txn_details = Transaction_Detail.objects.filter(transaction=txn)
if not all_txn_details.exists():
print(f"Reset: Không tìm thấy txn_detail nào cho product {product_id}")
return
# Bước 1: Reset tất cả lịch về pristine (chưa có allocation)
all_schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail__in=all_txn_details
)
unpaid_status = Payment_Status.objects.get(id=1)
reset_schedules_to_pristine(all_schedules, unpaid_status)
# Bước 2: Reset allocation của tất cả entry CR
reset_cr_entries_allocation(product_id)
# Bước 3: Tính lại Transaction và Transaction_Detail
# Sau reset, tất cả lịch đều unpaid → amount_received = 0
txn_total_received = Decimal('0')
for txn_detail in all_txn_details:
txn_detail.amount_received = Decimal('0')
txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0))
txn_detail.save(update_fields=['amount_received', 'amount_remaining'])
# Reset status detail về unpaid nếu còn nợ (chỉ nếu status khác 1)
if txn_detail.amount_remaining > 0 and txn_detail.status_id != 1:
try:
unpaid_txn_status = Transaction_Status.objects.get(id=1)
txn_detail.status = unpaid_txn_status
txn_detail.save(update_fields=['status'])
except Exception:
pass
txn.amount_received = Decimal('0')
txn.amount_remain = Decimal(str(txn.sale_price or 0))
txn.save(update_fields=['amount_received', 'amount_remain'])
# Transaction không có field status — không cần reset
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
f"Đã reset thành công trạng thái cho product_id={product_id}"
)
except Exception as e:
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
f"Lỗi khi reset product state for product_id={product_id}: {str(e)}"
)
import traceback
print(traceback.format_exc())
def background_allocate(product_id):
"""
Background task để chạy allocation.
Luồng xử lý: Reset toàn bộ → Phân bổ tiền trả nợ → Phân bổ miễn giảm.
"""
try:
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
f"Background allocation started for product_id={product_id}"
)
# 1. Reset toàn bộ trạng thái công nợ về gốc
reset_product_state_before_allocation(product_id)
# 2. Chạy phân bổ thanh toán (tiền khách trả)
normal_result = allocate_payment_to_schedules(product_id)
# 3. Chạy phân bổ miễn giảm (tiền công ty giảm cho khách)
reduction_result = allocate_penalty_reduction(product_id)
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
f"Background allocation completed for product_id={product_id}:"
)
print("Normal allocation result:", normal_result)
print("Penalty reduction result:", reduction_result)
except Exception as e:
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
f"Background allocation error for product_id={product_id}: {str(e)}"
)
import traceback
print(traceback.format_exc())
# ==========================================================================================
# API VIEWS
# ==========================================================================================
@api_view(['POST'])
def account_entry(request):
"""View function để tạo bút toán (được gọi từ urls.py)"""
ref = request.data.get('ref')
response_data, created_entry = account_entry_api(
code=request.data['code'],
amount=request.data['amount'],
content=request.data['content'],
type=request.data['type'],
category=request.data['category'],
userid=request.data['user'],
ref=ref,
product=request.data.get('product'),
customer=request.data.get('customer'),
date=request.data.get('date')
)
if 'error' in response_data:
return Response(response_data, status=400)
product_id = created_entry.product_id if created_entry else None
response = Response({
**response_data,
"message": "Bút toán đã tạo thành công. Phân bổ thanh toán đang chạy ngầm..."
})
if product_id:
def run_allocation():
thread = threading.Thread(
target=background_allocate, args=(product_id,), daemon=True
)
thread.start()
transaction.on_commit(run_allocation)
return response
@api_view(['POST'])
def account_multi_entry(request):
"""Tạo nhiều bút toán cùng lúc"""
try:
result = []
product_ids = set()
data_list = request.data.get('data', [])
with transaction.atomic():
for obj in data_list:
response_data, created_entry = account_entry_api(
code=obj['Tài khoản'],
amount=obj['amount'],
content=obj['content'],
type='CR',
category=obj['category'],
userid=request.data.get('user'),
ref=obj.get('ref'),
product=obj.get('product'),
customer=obj.get('customer'),
date=obj.get('date')
)
result.append(response_data)
if created_entry and created_entry.product_id:
product_ids.add(created_entry.product_id)
response = Response({
"entries": result,
"message": (
f"Bút toán đã tạo thành công. "
f"Phân bổ thanh toán đang chạy ngầm cho {len(product_ids)} sản phẩm..."
)
})
if product_ids:
def run_allocations():
for pid in product_ids:
thread = threading.Thread(
target=background_allocate, args=(pid,), daemon=True
)
thread.start()
transaction.on_commit(run_allocations)
return response
except Exception as e:
print({'error': f"Đã xảy ra lỗi không mong muốn: {str(e)}"})
return Response({'error': str(e)}, status=400)
@api_view(['POST'])
def delete_entry(request):
"""
Xóa bút toán.
Luồng: Reset sạch tất cả lịch & entry CR → Hoàn tác số dư tài khoản →
Xóa entry → Phân bổ lại toàn bộ (on_commit).
"""
entry_id = request.data.get('id')
if not entry_id:
return Response({'error': 'Thiếu id bút toán'}, status=400)
try:
with transaction.atomic():
try:
entry = Internal_Entry.objects.select_for_update().get(id=entry_id)
except Internal_Entry.DoesNotExist:
return Response(
{'error': f'Bút toán với ID {entry_id} không tồn tại'}, status=404
)
entry_info = {
'id': entry.id,
'code': entry.code,
'amount': float(entry.amount),
'type': entry.type.code,
'account_code': entry.account.code,
'account_id': entry.account_id,
'product_id': entry.product_id if entry.product else None,
'allocation_amount': float(entry.allocation_amount or 0),
'allocation_remain': float(entry.allocation_remain or 0)
}
if entry.type.code != 'CR':
return Response(
{'error': 'Hiện chỉ hỗ trợ xóa bút toán thu tiền (CR)'}, status=400
)
product_id = entry.product_id
if not product_id:
return Response(
{'error': 'Bút toán không gắn với product nào'}, status=400
)
# =================================================================
# Bước 1: Reset tất cả lịch của transaction về pristine
# =================================================================
booked = Product_Booked.objects.filter(product=entry.product).first()
if not (booked and booked.transaction):
return Response(
{'error': 'Không tìm thấy Transaction cho product này'}, status=400
)
txn = booked.transaction
all_txn_details = Transaction_Detail.objects.filter(transaction=txn)
all_schedules = Payment_Schedule.objects.select_for_update().filter(
txn_detail__in=all_txn_details
)
unpaid_status = Payment_Status.objects.get(id=1)
reset_schedules_to_pristine(all_schedules, unpaid_status)
# =================================================================
# Bước 2: Reset allocation của tất cả entry CR,
# NGOẠI TRỪ entry đang bị xóa (sẽ bị xóa sau)
# =================================================================
reset_cr_entries_allocation(product_id, exclude_entry_id=entry_id)
# =================================================================
# Bước 3: Hoàn tác số dư tài khoản
# =================================================================
account = Internal_Account.objects.select_for_update().get(id=entry.account_id)
entry_amount = float(entry.amount)
if entry.type.code == 'CR':
account.balance = (account.balance or 0) - entry_amount
else:
account.balance = (account.balance or 0) + entry_amount
account.save(update_fields=['balance'])
# =================================================================
# Bước 4: Xóa entry
# =================================================================
entry.delete()
# =================================================================
# Bước 5: Đặt lại Transaction & Transaction_Detail về unpaid
# (amount_received = 0 vì tất cả lịch đã reset về pristine)
# =================================================================
for txn_detail in all_txn_details:
txn_detail.amount_received = Decimal('0')
txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0))
txn_detail.save(update_fields=['amount_received', 'amount_remaining'])
if txn_detail.amount_remaining > 0 and txn_detail.status_id != 1:
try:
unpaid_txn_status = Transaction_Status.objects.get(id=1)
txn_detail.status = unpaid_txn_status
txn_detail.save(update_fields=['status'])
except Exception:
pass
txn.amount_received = Decimal('0')
txn.amount_remain = Decimal(str(txn.sale_price or 0))
txn.save(update_fields=['amount_received', 'amount_remain'])
# Transaction không có field status — không cần reset
# =================================================================
# Bước 6: Phân bổ lại toàn bộ sản phẩm (on_commit)
# Chạy allocate trực tiếp (KHÔNG reset lại nữa, vì đã reset ở trên)
# =================================================================
def trigger_reallocate():
if product_id:
try:
allocate_payment_to_schedules(product_id)
allocate_penalty_reduction(product_id)
except Exception as exc:
import traceback
print(f"Lỗi khi re-allocate sau xóa: {exc}")
traceback.print_exc()
transaction.on_commit(trigger_reallocate)
return Response({
'success': True,
'message': (
'Đã xóa bút toán, reset sạch tất cả lịch, '
'hoàn tác số dư tài khoản, đang phân bổ lại toàn bộ...'
),
'entry': entry_info,
'account_balance_restored': True
})
except Exception as e:
import traceback
print(traceback.format_exc())
return Response(
{'error': f'Đã xảy ra lỗi khi xóa bút toán: {str(e)}'}, status=500
)