1023 lines
48 KiB
Python
1023 lines
48 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'
|
|
|
|
|
|
# ==========================================================================================
|
|
|
|
DAILY_PENALTY_RATE = Decimal('0.0005') # 0.05% mỗi ngày
|
|
|
|
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 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%
|
|
|
|
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
|
|
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)
|
|
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 nợ (chỉ lấy status=1)
|
|
schedules = Payment_Schedule.objects.select_for_update().filter(
|
|
txn_detail=txn_detail,
|
|
status__id=1
|
|
).order_by('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
|
|
|
|
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
|
|
|
|
# --- 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
|
|
|
|
# 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)))
|
|
|
|
# --- 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
|
|
|
|
# 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
|
|
|
|
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)
|
|
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}")
|
|
|
|
# --- 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
|
|
print(f"Lai la : {penalty_to_this_entry + additional_penalty_to_today} = {sch.penalty_amount}")
|
|
# Ghi Trace
|
|
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_after_allocation": float(amount_remain_after),
|
|
})
|
|
sch.entry = sch_entry_list # Lưu lại list
|
|
|
|
if 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)
|
|
|
|
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 (Giữ nguyên gốc)
|
|
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()
|
|
|
|
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
|
|
|
|
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}
|
|
|
|
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": []
|
|
}
|
|
|
|
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')
|
|
|
|
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": []
|
|
}
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
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'])
|
|
|
|
# KIỂM TRA ĐỂ ĐÓNG LỊCH
|
|
if schedule.remain_amount <= 0 and schedule.amount_remain <= 0:
|
|
try:
|
|
paid_status = Payment_Status.objects.get(id=2)
|
|
schedule.status = paid_status
|
|
schedule.save(update_fields=['status'])
|
|
except Payment_Status.DoesNotExist:
|
|
errors.append("Không tìm thấy Payment_Status id=2")
|
|
|
|
if schedule.id not in updated_schedules:
|
|
updated_schedules.append(schedule.id)
|
|
|
|
entry_allocation_detail.append({
|
|
"schedule_id": schedule.id, "schedule_code": schedule.code,
|
|
"amount": float(to_reduce), "type": "REDUCTION",
|
|
"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 TOÀN BỘ GIAO DỊCH
|
|
try:
|
|
txn_detail.refresh_from_db()
|
|
has_pending_sch = Payment_Schedule.objects.filter(txn_detail=txn_detail, status__id=1).exists()
|
|
|
|
if txn_detail.amount_remaining <= 0 and not has_pending_sch:
|
|
paid_txn_status = Transaction_Status.objects.get(id=2)
|
|
txn_detail.status = paid_txn_status
|
|
txn.status = paid_txn_status
|
|
txn_detail.save(update_fields=['status'])
|
|
txn.save(update_fields=['status'])
|
|
print(f"Transaction for product {product_id} closed after penalty reduction.")
|
|
except Transaction_Status.DoesNotExist:
|
|
errors.append("Không tìm thấy Transaction_Status id=2")
|
|
except Exception as e:
|
|
errors.append(f"Lỗi khi đóng transaction sau miễn giảm: {str(e)}")
|
|
|
|
except Exception as exc:
|
|
errors.append(str(exc))
|
|
import traceback
|
|
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ổ.
|
|
Sử dụng logic tính toán giống delete_entry nhưng không xóa entry và không hoàn tác tài khoản.
|
|
"""
|
|
with transaction.atomic():
|
|
try:
|
|
product = Product.objects.get(id=product_id)
|
|
booked = Product_Booked.objects.filter(product=product).first()
|
|
if not (booked and booked.transaction):
|
|
print(f"Reset: Không tìm thấy transaction cho product {product_id}")
|
|
return
|
|
|
|
txn = booked.transaction
|
|
|
|
try:
|
|
current = Transaction_Current.objects.get(transaction=txn)
|
|
txn_detail = current.detail
|
|
except Transaction_Current.DoesNotExist:
|
|
txn_detail = Transaction_Detail.objects.filter(transaction=txn).order_by('-create_time').first()
|
|
|
|
if not txn_detail:
|
|
print(f"Reset: Không tìm thấy txn_detail cho product {product_id}")
|
|
return
|
|
|
|
# =================================================================
|
|
# Bước 1: Reset các lịch thanh toán theo công thức (GIỐNG delete_entry)
|
|
# =================================================================
|
|
all_schedules = Payment_Schedule.objects.select_for_update().filter(txn_detail=txn_detail)
|
|
unpaid_status = Payment_Status.objects.get(id=1)
|
|
|
|
for schedule in all_schedules:
|
|
current_amount_remain = Decimal(str(schedule.amount_remain or 0))
|
|
|
|
# Tính tổng principal đã phân bổ vào đúng lịch này từ tất cả entry CR
|
|
principal_allocated_to_schedule = Decimal('0')
|
|
for e in Internal_Entry.objects.filter(product_id=product_id, type__code='CR'):
|
|
for alloc in (e.allocation_detail or []):
|
|
if alloc.get('schedule_id') == schedule.id:
|
|
principal_allocated_to_schedule += Decimal(str(alloc.get('principal', 0)))
|
|
|
|
# Reset theo công thức - GIỐNG delete_entry
|
|
schedule.entry = []
|
|
schedule.amount_remain = current_amount_remain + principal_allocated_to_schedule
|
|
schedule.remain_amount = schedule.amount_remain
|
|
schedule.paid_amount = schedule.amount - schedule.amount_remain
|
|
schedule.penalty_paid = Decimal('0')
|
|
schedule.penalty_reduce = Decimal('0')
|
|
schedule.penalty_amount = Decimal('0')
|
|
schedule.penalty_remain = Decimal('0')
|
|
schedule.ovd_days = 0
|
|
schedule.status = unpaid_status
|
|
|
|
schedule.save(update_fields=[
|
|
'entry', 'paid_amount', 'amount_remain', 'remain_amount',
|
|
'penalty_paid', 'penalty_reduce', 'penalty_remain', 'ovd_days', 'status', 'penalty_amount'
|
|
])
|
|
|
|
# =================================================================
|
|
# Bước 2: Reset allocation của tất cả entry CR của sản phẩm (GIỐNG delete_entry)
|
|
# =================================================================
|
|
all_cr_entries = Internal_Entry.objects.filter(
|
|
product_id=product_id,
|
|
type__code='CR'
|
|
)
|
|
for e in all_cr_entries:
|
|
e.allocation_detail = []
|
|
e.allocation_amount = Decimal('0')
|
|
e.allocation_remain = Decimal(str(e.amount))
|
|
e.save(update_fields=[
|
|
'allocation_detail', 'allocation_amount', 'allocation_remain'
|
|
])
|
|
|
|
# =================================================================
|
|
# Bước 3: Đặt lại Transaction_Detail từ các lịch và LƯU TRƯỚC (GIỐNG delete_entry)
|
|
# =================================================================
|
|
if txn_detail:
|
|
# Lấy tổng paid của các lịch THUỘC detail này
|
|
schedules_of_this_detail = Payment_Schedule.objects.filter(txn_detail=txn_detail)
|
|
detail_total_paid = Decimal('0')
|
|
|
|
for sch in schedules_of_this_detail:
|
|
paid = Decimal(str(sch.paid_amount or 0))
|
|
detail_total_paid += paid
|
|
|
|
# Cập nhật Transaction_Detail
|
|
txn_detail.amount_received = detail_total_paid
|
|
txn_detail.amount_remaining = Decimal(str(txn_detail.amount or 0)) - detail_total_paid
|
|
|
|
# ===== LƯU NGAY TRANSACTION_DETAIL =====
|
|
txn_detail.save(update_fields=['amount_received', 'amount_remaining'])
|
|
txn_detail.refresh_from_db()
|
|
|
|
# Đặt lại status về unpaid nếu còn nợ
|
|
if txn_detail.amount_remaining > 0:
|
|
try:
|
|
unpaid_txn_status = Transaction_Status.objects.get(id=1)
|
|
txn_detail.status = unpaid_txn_status
|
|
txn_detail.save(update_fields=['status'])
|
|
except:
|
|
pass
|
|
|
|
# =================================================================
|
|
# Bước 4: SAU KHI LƯU TXN_DETAIL, Query lại TẤT CẢ DETAIL và tính Transaction (GIỐNG delete_entry)
|
|
# =================================================================
|
|
# Query lại TẤT CẢ detail sau khi đã lưu
|
|
all_details = Transaction_Detail.objects.filter(transaction=txn)
|
|
|
|
txn_total_received = Decimal('0')
|
|
txn_total_deposit_received = Decimal('0')
|
|
|
|
for detail in all_details:
|
|
# Refresh để lấy giá trị mới nhất từ DB
|
|
detail.refresh_from_db()
|
|
txn_total_received += Decimal(str(detail.amount_received or 0))
|
|
|
|
# Tính deposit từ các lịch deposit thuộc detail này
|
|
deposit_schedules = Payment_Schedule.objects.filter(
|
|
txn_detail=detail,
|
|
type_id=1
|
|
)
|
|
for dep_sch in deposit_schedules:
|
|
txn_total_deposit_received += Decimal(str(dep_sch.paid_amount or 0))
|
|
|
|
# Cập nhật Transaction - SỬA: amount -> sale_price
|
|
txn.amount_received = txn_total_received
|
|
txn.amount_remain = Decimal(str(txn.sale_price or 0)) - txn_total_received # ← SỬA ĐÂY
|
|
|
|
if hasattr(txn, 'deposit_received'):
|
|
txn.deposit_received = txn_total_deposit_received
|
|
if hasattr(txn, 'deposit_remaining') and hasattr(txn, 'deposit_amount'):
|
|
txn.deposit_remaining = Decimal(str(txn.deposit_amount or 0)) - txn_total_deposit_received
|
|
|
|
# ===== LƯU TRANSACTION =====
|
|
txn.save(update_fields=['amount_received', 'amount_remain', 'deposit_received', 'deposit_remaining'])
|
|
txn.refresh_from_db()
|
|
|
|
# Đặt lại status về unpaid nếu còn nợ
|
|
if txn.amount_remain > 0:
|
|
try:
|
|
unpaid_txn_status = Transaction_Status.objects.get(id=1)
|
|
txn.status = unpaid_txn_status
|
|
txn.save(update_fields=['status'])
|
|
except:
|
|
pass
|
|
|
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Đã reset thành công trạng thái cho product_id={product_id}")
|
|
|
|
except Exception as e:
|
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Lỗi khi reset product state for product_id={product_id}: {str(e)}")
|
|
import traceback
|
|
print(traceback.format_exc())
|
|
|
|
|
|
def background_allocate(product_id):
|
|
"""
|
|
Background task để chạy allocation.
|
|
Luồng xử lý: Reset toàn bộ -> Phân bổ tiền trả nợ -> Phân bổ miễn giảm.
|
|
"""
|
|
try:
|
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation started for product_id={product_id}")
|
|
|
|
# 1. Reset toàn bộ trạng thái công nợ của sản phẩm về gốc
|
|
reset_product_state_before_allocation(product_id)
|
|
|
|
# 2. Chạy phân bổ thanh toán (tiền khách trả)
|
|
normal_result = allocate_payment_to_schedules(product_id)
|
|
|
|
# 3. Chạy phân bổ miễn giảm (tiền công ty giảm cho khách)
|
|
reduction_result = allocate_penalty_reduction(product_id)
|
|
|
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Background allocation completed for product_id={product_id}:")
|
|
print("Normal 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)}")
|
|
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. 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:
|
|
thread = threading.Thread(
|
|
target=background_allocate,
|
|
args=(product_id,),
|
|
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 - 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"""
|
|
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)
|
|
|
|
allocation_detail = entry.allocation_detail or []
|
|
schedules_reversed = []
|
|
|
|
# =================================================================
|
|
# Bước 1: Reset các lịch bị ảnh hưởng theo công thức & lưu
|
|
# =================================================================
|
|
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')
|
|
|
|
if not schedule_id or allocated_amount <= 0:
|
|
continue
|
|
|
|
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'
|
|
)
|
|
for e in all_cr_entries:
|
|
e.allocation_detail = []
|
|
e.allocation_amount = Decimal('0')
|
|
e.allocation_remain = Decimal(str(e.amount))
|
|
e.save(update_fields=[
|
|
'allocation_detail', 'allocation_amount', 'allocation_remain'
|
|
])
|
|
|
|
# =================================================================
|
|
# Bước 3: Hoàn tác số dư tài khoản (lưu trước khi xóa entry)
|
|
# =================================================================
|
|
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 (sau khi reset và lưu hết)
|
|
# =================================================================
|
|
entry.delete()
|
|
|
|
# =================================================================
|
|
# Bước 5: ĐẶT LẠI Transaction & Transaction_Detail TRƯỚC KHI PHÂN BỔ LẠI
|
|
# =================================================================
|
|
txn_detail_updated = False
|
|
txn_updated = False
|
|
|
|
if entry.product:
|
|
try:
|
|
booked = Product_Booked.objects.filter(product=entry.product).first()
|
|
if booked and booked.transaction:
|
|
txn = booked.transaction
|
|
|
|
# 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()
|
|
|
|
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)}")
|
|
|
|
# =================================================================
|
|
# Bước 6: Phân bổ lại toàn bộ sản phẩm (sẽ tự tính lại txn/txndetail đúng)
|
|
# =================================================================
|
|
def trigger_reallocate():
|
|
if product_id:
|
|
try:
|
|
allocate_payment_to_schedules(product_id)
|
|
allocate_penalty_reduction(product_id)
|
|
except Exception as exc:
|
|
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 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ộ...',
|
|
'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) |