Files
shopship/apps/bizapps/api/views.py
T
2026-09-04 13:05:54 +03:30

490 lines
18 KiB
Python

import uuid
from django.db.models import Sum
from django.http import HttpResponse
from django.utils.dateparse import parse_date
from rest_framework import generics, status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.bizapps.models import (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
from .accounting import (
ChartOfAccountsService,
CustomerStatementService,
LedgerBookService,
SubsidiaryLedgerService,
)
from .serializers import (
AccountSerializer,
AdjustStockSerializer,
CreateInvoiceSerializer,
CustomerSerializer,
InvoiceSerializer,
LedgerBookSerializer,
LedgerEntrySerializer,
LedgerInstallmentSerializer,
OpenLedgerBookSerializer,
ProductSerializer,
StockMovementSerializer,
TransactionSerializer,
UpdateInvoiceStatusSerializer,
)
from .services import InsufficientStockError, InvoiceService, LedgerService
class _OwnedMixin:
"""همه‌ی منابع این اپ فقط متعلق به کاربرِ درخواست‌دهنده‌اند."""
permission_classes = [IsAuthenticated]
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
# ---------------- مشتریان ----------------
class CustomerListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = Customer
serializer_class = CustomerSerializer
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class CustomerDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
model = Customer
serializer_class = CustomerSerializer
class CustomerStatementView(APIView):
"""صورت‌حسابِ کاملِ یک مشتری — فاکتورها + بدهی‌های باز + اقساط، یک‌جا.
همان اتصالِ «مشتری ↔ فاکتور ↔ حسابداری» به‌شکلِ یک پاسخِ واحد."""
permission_classes = [IsAuthenticated]
def get(self, request, pk):
customer = Customer.objects.filter(user=request.user, pk=pk).first()
if customer is None:
raise NotFound("مشتری یافت نشد.")
return Response(CustomerStatementService.build(request.user, customer))
# ---------------- دفتر درآمد و هزینه ----------------
class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
def get_queryset(self):
qs = super().get_queryset().prefetch_related("installments").select_related("account")
# پیش‌فرض فقط فایلِ جاری؟ نه — book=<id> صریح، وگرنه همه؛ تا
# گزارش‌های چنددوره‌ای هم ممکن بماند.
book_id = self.request.query_params.get("book")
if book_id:
qs = qs.filter(book_id=book_id)
start = parse_date(self.request.query_params.get("start", "") or "")
end = parse_date(self.request.query_params.get("end", "") or "")
if start:
qs = qs.filter(entry_date__gte=start)
if end:
qs = qs.filter(entry_date__lte=end)
return qs
def perform_create(self, serializer):
book = LedgerBookService.ensure_active_book(self.request.user)
entry = serializer.save(user=self.request.user, book=book)
LedgerService.sync_entry_account(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
def get_queryset(self):
return super().get_queryset().prefetch_related("installments").select_related("account")
def perform_update(self, serializer):
entry = serializer.save()
LedgerService.sync_entry_account(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
def perform_destroy(self, instance):
phone = instance.counterparty_phone
instance.delete()
if phone:
LedgerService.recompute_customer_balance(self.request.user, phone)
class LedgerInstallmentListCreateView(generics.ListCreateAPIView):
serializer_class = LedgerInstallmentSerializer
permission_classes = [IsAuthenticated]
def _entry(self) -> LedgerEntry:
entry = LedgerEntry.objects.filter(
user=self.request.user, pk=self.kwargs["entry_id"]
).prefetch_related("installments").first()
if entry is None:
raise NotFound("ردیف دفتر یافت نشد.")
return entry
def get_serializer_context(self):
context = super().get_serializer_context()
context["ledger_entry"] = self._entry()
return context
def get_queryset(self):
return self._entry().installments.all()
def perform_create(self, serializer):
entry = self._entry()
installment = serializer.save(id=str(uuid.uuid4()), ledger_entry=entry)
# هر قسط، هم مانده‌ی مشتری را به‌روز می‌کند هم یک سندِ حسابداری
# دوطرفه می‌سازد — همان اتصالِ دفتر به درختِ حساب‌ها.
LedgerService.post_installment_transaction(self.request.user, installment)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
class LedgerInstallmentDetailView(generics.DestroyAPIView):
serializer_class = LedgerInstallmentSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return LedgerInstallment.objects.filter(ledger_entry__user=self.request.user)
def perform_destroy(self, instance):
phone = instance.ledger_entry.counterparty_phone
instance.delete()
if phone:
LedgerService.recompute_customer_balance(self.request.user, phone)
class LedgerExportView(APIView):
"""خروجیِ دفتر — پیش‌فرض اکسلِ چندبرگی (?type=csv برای نسخه‌ی ساده).
عمداً `type` و نه `format`: پارامترِ `format` را خودِ DRF برای مذاکره‌ی
محتوا رزرو کرده و `?format=csv` را به‌عنوان درخواستِ رندررِ ثبت‌نشده‌ی
csv تفسیر و ۴۰۴ می‌دهد."""
permission_classes = [IsAuthenticated]
def get(self, request):
start = parse_date(request.query_params.get("start", "") or "")
end = parse_date(request.query_params.get("end", "") or "")
if request.query_params.get("type") == "csv":
content = LedgerService.export_csv(request.user, start, end)
response = HttpResponse(content, content_type="text/csv; charset=utf-8-sig")
response["Content-Disposition"] = 'attachment; filename="ledger.csv"'
return response
content = LedgerService.export_xlsx(request.user, start, end)
response = HttpResponse(
content,
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response["Content-Disposition"] = 'attachment; filename="shopship-report.xlsx"'
return response
# ---------------- درخت حساب‌ها ----------------
class AccountListView(generics.ListCreateAPIView):
serializer_class = AccountSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
ChartOfAccountsService.ensure_chart(self.request.user)
qs = Account.objects.filter(user=self.request.user).select_related("parent")
level = self.request.query_params.get("level")
if level:
qs = qs.filter(level=level)
return qs
def perform_create(self, serializer):
serializer.save(id=str(uuid.uuid4()), user=self.request.user, is_system=False)
class AccountDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = AccountSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Account.objects.filter(user=self.request.user)
def perform_destroy(self, instance):
# حساب‌های سیستمی پایه‌ی سندهای خودکارند؛ حذفشان گزارش‌ها را می‌شکند.
if instance.is_system:
raise ValidationError({"detail": "حساب‌های سیستمی قابل حذف نیستند."})
instance.delete()
class TrialBalanceView(APIView):
"""ترازِ حساب‌ها — جمعِ بدهکار/بستانکار و مانده‌ی هر حسابِ معین."""
permission_classes = [IsAuthenticated]
def get(self, request):
start = parse_date(request.query_params.get("start", "") or "")
end = parse_date(request.query_params.get("end", "") or "")
return Response(ChartOfAccountsService.trial_balance(request.user, start, end))
class TransactionListView(generics.ListAPIView):
serializer_class = TransactionSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Transaction.objects.filter(user=self.request.user).select_related(
"debit_account_ref", "credit_account_ref"
)
# ---------------- انبار ----------------
class ProductListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = Product
serializer_class = ProductSerializer
def perform_create(self, serializer):
product = serializer.save(user=self.request.user)
product.recompute_purchase_value()
product.save(update_fields=["total_purchase_value", "updated_at"])
class ProductDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
model = Product
serializer_class = ProductSerializer
def perform_update(self, serializer):
product = serializer.save()
product.recompute_purchase_value()
product.save(update_fields=["total_purchase_value", "updated_at"])
class ProductAdjustStockView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
product = Product.objects.filter(user=request.user, pk=pk).first()
if product is None:
raise NotFound("کالا یافت نشد.")
serializer = AdjustStockSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
product.quantity += serializer.validated_data["delta"]
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=serializer.validated_data["delta"],
quantity_after=product.quantity,
note=serializer.validated_data.get("note", ""),
)
return Response(ProductSerializer(product).data)
class ProductMovementsView(generics.ListAPIView):
serializer_class = StockMovementSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return StockMovement.objects.filter(
product__user=self.request.user, product_id=self.kwargs["pk"]
)
class InventorySummaryView(APIView):
"""خلاصه‌ی انبار — ارزشِ کل، تعداد اقلام، و کالاهای روی آستانه‌ی کمبود."""
permission_classes = [IsAuthenticated]
def get(self, request):
products = Product.objects.filter(user=request.user)
low = [p for p in products if p.is_low_stock]
return Response(
{
"total_products": products.count(),
"total_value": products.aggregate(v=Sum("total_purchase_value"))["v"] or 0,
"low_stock_count": len(low),
"low_stock": ProductSerializer(low, many=True).data,
}
)
# ---------------- فاکتور ----------------
class InvoiceListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = Invoice
serializer_class = InvoiceSerializer
def get_queryset(self):
return super().get_queryset().prefetch_related("items")
def create(self, request, *args, **kwargs):
serializer = CreateInvoiceSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = dict(serializer.validated_data)
send_sms = data.pop("send_sms", False)
try:
invoice = InvoiceService.create_invoice(request.user, data, send_sms=send_sms)
except InsufficientStockError as exc:
raise ValidationError({"detail": str(exc)})
return Response(InvoiceSerializer(invoice).data, status=status.HTTP_201_CREATED)
class InvoiceDetailView(_OwnedMixin, generics.RetrieveAPIView):
model = Invoice
serializer_class = InvoiceSerializer
def get_queryset(self):
return super().get_queryset().prefetch_related("items")
class InvoiceStatusView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
serializer = UpdateInvoiceStatusSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
invoice = InvoiceService.update_status(request.user, pk, **serializer.validated_data)
except Invoice.DoesNotExist:
raise NotFound("فاکتور یافت نشد.")
return Response(InvoiceSerializer(invoice).data)
class InvoiceCancelView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
try:
invoice = InvoiceService.cancel_invoice(request.user, pk)
except Invoice.DoesNotExist:
raise NotFound("فاکتور یافت نشد.")
except ValueError as exc:
raise ValidationError({"detail": str(exc)})
return Response(InvoiceSerializer(invoice).data)
class BusinessDashboardView(APIView):
"""داشبوردِ یکپارچه — همان «اتصالِ بخش‌ها» به‌شکلِ یک پاسخ: فروش، بدهی،
انبار و فاکتورها کنار هم، به‌جای چهار درخواستِ جدا از چهار صفحه."""
permission_classes = [IsAuthenticated]
def get(self, request):
user = request.user
entries = LedgerEntry.objects.filter(user=user).prefetch_related("installments")
products = Product.objects.filter(user=user)
invoices = Invoice.objects.filter(user=user)
income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME)
expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE)
receivable = sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT
)
payable = sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT
)
return Response(
{
"total_income": income,
"total_expense": expense,
"net_profit": income - expense,
"open_receivable": receivable,
"open_payable": payable,
"inventory_value": products.aggregate(v=Sum("total_purchase_value"))["v"] or 0,
"low_stock_count": sum(1 for p in products if p.is_low_stock),
"invoice_count": invoices.count(),
"unpaid_invoice_count": invoices.filter(
payment_status=Invoice.PaymentStatus.UNPAID
).exclude(order_status=Invoice.OrderStatus.CANCELLED).count(),
"customer_count": Customer.objects.filter(user=user).count(),
"debtor_count": Customer.objects.filter(user=user, outstanding_balance__gt=0).count(),
}
)
# ---------------- فایل‌های دفتر و دفتر معین ----------------
class LedgerBookListView(generics.ListAPIView):
"""فهرست فایل‌های دفتر — همیشه حداقل یک فایلِ جاری وجود دارد."""
serializer_class = LedgerBookSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
LedgerBookService.ensure_active_book(self.request.user)
return LedgerBook.objects.filter(user=self.request.user)
class OpenLedgerBookView(APIView):
"""باز کردن فایل تازه — فایلِ جاری بسته می‌شود و (در صورت انتخاب) مانده
به فایل جدید منتقل می‌گردد."""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = OpenLedgerBookSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
book = LedgerBookService.open_new_book(
request.user,
name=serializer.validated_data["name"],
start_date=serializer.validated_data["start_date"],
end_date=serializer.validated_data.get("end_date"),
carry_balance=serializer.validated_data.get("carry_balance", True),
)
return Response(LedgerBookSerializer(book).data, status=status.HTTP_201_CREATED)
class LedgerBookSummaryView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, pk):
book = LedgerBook.objects.filter(user=request.user, pk=pk).first()
if book is None:
raise NotFound("فایل دفتر یافت نشد.")
return Response(
{"book": LedgerBookSerializer(book).data, **LedgerBookService.summary(book)}
)
class SubsidiaryLedgerView(APIView):
"""دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان."""
permission_classes = [IsAuthenticated]
def get(self, request, pk):
account = Account.objects.filter(user=request.user, pk=pk).first()
if account is None:
raise NotFound("حساب یافت نشد.")
start = parse_date(request.query_params.get("start", "") or "")
end = parse_date(request.query_params.get("end", "") or "")
return Response(
SubsidiaryLedgerService.statement(request.user, account, start, end)
)