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= صریح، وگرنه همه؛ تا # گزارش‌های چنددوره‌ای هم ممکن بماند. 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) LedgerService.post_entry_transaction(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) LedgerService.post_entry_transaction(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 LedgerBookDetailView(generics.RetrieveUpdateDestroyAPIView): """ویرایش/حذفِ یک فایل دفتر. فایلِ بایگانی‌شده فقط-خواندنی است — نه ویرایش و نه حذف. حذف فقط برای فایلِ خالی (بدون هیچ ثبتی) مجاز است تا داده‌ی مالی با حذفِ فایل به‌طور آبشاری از بین نرود.""" serializer_class = LedgerBookSerializer permission_classes = [IsAuthenticated] def get_queryset(self): return LedgerBook.objects.filter(user=self.request.user) def _guard_editable(self, book: LedgerBook): if book.is_closed: raise ValidationError("فایلِ بایگانی‌شده فقط-خواندنی است.") def perform_update(self, serializer): self._guard_editable(serializer.instance) serializer.save() def perform_destroy(self, instance): self._guard_editable(instance) if instance.entries.exists(): raise ValidationError("فقط فایلِ خالی (بدون هیچ ثبتی) قابل‌حذف است.") instance.delete() class LedgerBookArchiveView(APIView): """بایگانی‌کردنِ دستیِ یک فایل دفتر — از این پس فقط-خواندنی می‌شود و در انتخابِ فایلِ دفتر برای فاکتورهای تازه نشان داده نمی‌شود.""" permission_classes = [IsAuthenticated] def post(self, request, pk): book = LedgerBook.objects.filter(user=request.user, pk=pk).first() if book is None: raise NotFound("فایل دفتر یافت نشد.") if book.is_closed: raise ValidationError("این فایل قبلا بایگانی شده.") book = LedgerBookService.archive_book(book) return Response(LedgerBookSerializer(book).data) 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) )