2026-09-04 13:05:54 +03:30
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from django.db.models import Sum
|
|
|
|
|
from django.http import HttpResponse
|
|
|
|
|
from django.utils.dateparse import parse_date
|
2026-08-24 15:54:08 +03:30
|
|
|
from rest_framework import generics, status
|
2026-09-04 13:05:54 +03:30
|
|
|
from rest_framework.exceptions import NotFound, ValidationError
|
2026-08-24 15:54:08 +03:30
|
|
|
from rest_framework.permissions import IsAuthenticated
|
|
|
|
|
from rest_framework.response import Response
|
|
|
|
|
from rest_framework.views import APIView
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
from apps.bizapps.models import (
|
|
|
|
|
Account,
|
|
|
|
|
Customer,
|
|
|
|
|
Invoice,
|
|
|
|
|
LedgerBook,
|
|
|
|
|
LedgerEntry,
|
|
|
|
|
LedgerInstallment,
|
|
|
|
|
Product,
|
|
|
|
|
StockMovement,
|
|
|
|
|
Transaction,
|
|
|
|
|
)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
from .accounting import (
|
|
|
|
|
ChartOfAccountsService,
|
|
|
|
|
CustomerStatementService,
|
|
|
|
|
LedgerBookService,
|
|
|
|
|
SubsidiaryLedgerService,
|
|
|
|
|
)
|
2026-08-24 15:54:08 +03:30
|
|
|
from .serializers import (
|
2026-09-04 13:05:54 +03:30
|
|
|
AccountSerializer,
|
2026-08-24 15:54:08 +03:30
|
|
|
AdjustStockSerializer,
|
2026-09-04 13:05:54 +03:30
|
|
|
CreateInvoiceSerializer,
|
2026-08-24 15:54:08 +03:30
|
|
|
CustomerSerializer,
|
2026-09-04 13:05:54 +03:30
|
|
|
InvoiceSerializer,
|
|
|
|
|
LedgerBookSerializer,
|
2026-08-24 15:54:08 +03:30
|
|
|
LedgerEntrySerializer,
|
2026-09-04 13:05:54 +03:30
|
|
|
LedgerInstallmentSerializer,
|
|
|
|
|
OpenLedgerBookSerializer,
|
2026-08-24 15:54:08 +03:30
|
|
|
ProductSerializer,
|
|
|
|
|
StockMovementSerializer,
|
2026-09-04 13:05:54 +03:30
|
|
|
TransactionSerializer,
|
|
|
|
|
UpdateInvoiceStatusSerializer,
|
2026-08-24 15:54:08 +03:30
|
|
|
)
|
2026-09-04 13:05:54 +03:30
|
|
|
from .services import InsufficientStockError, InvoiceService, LedgerService
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
class _OwnedMixin:
|
|
|
|
|
"""همهی منابع این اپ فقط متعلق به کاربرِ درخواستدهندهاند."""
|
2026-08-24 15:54:08 +03:30
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
permission_classes = [IsAuthenticated]
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
def get_queryset(self):
|
|
|
|
|
return self.model.objects.filter(user=self.request.user)
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
# ---------------- مشتریان ----------------
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
class CustomerListCreateView(_OwnedMixin, generics.ListCreateAPIView):
|
2026-08-24 15:54:08 +03:30
|
|
|
model = Customer
|
|
|
|
|
serializer_class = CustomerSerializer
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
def perform_create(self, serializer):
|
|
|
|
|
serializer.save(user=self.request.user)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
class CustomerDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
|
2026-08-24 15:54:08 +03:30
|
|
|
model = Customer
|
|
|
|
|
serializer_class = CustomerSerializer
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
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):
|
2026-08-24 15:54:08 +03:30
|
|
|
model = LedgerEntry
|
|
|
|
|
serializer_class = LedgerEntrySerializer
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
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)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
|
2026-08-24 15:54:08 +03:30
|
|
|
model = LedgerEntry
|
|
|
|
|
serializer_class = LedgerEntrySerializer
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
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"
|
|
|
|
|
)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
# ---------------- انبار ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductListCreateView(_OwnedMixin, generics.ListCreateAPIView):
|
2026-08-24 15:54:08 +03:30
|
|
|
model = Product
|
|
|
|
|
serializer_class = ProductSerializer
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
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"])
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
class ProductDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
|
2026-08-24 15:54:08 +03:30
|
|
|
model = Product
|
|
|
|
|
serializer_class = ProductSerializer
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
def perform_update(self, serializer):
|
|
|
|
|
product = serializer.save()
|
|
|
|
|
product.recompute_purchase_value()
|
|
|
|
|
product.save(update_fields=["total_purchase_value", "updated_at"])
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
class ProductAdjustStockView(APIView):
|
|
|
|
|
permission_classes = [IsAuthenticated]
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
def post(self, request, pk):
|
2026-09-04 13:05:54 +03:30
|
|
|
product = Product.objects.filter(user=request.user, pk=pk).first()
|
|
|
|
|
if product is None:
|
2026-08-24 15:54:08 +03:30
|
|
|
raise NotFound("کالا یافت نشد.")
|
|
|
|
|
serializer = AdjustStockSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
product.quantity += serializer.validated_data["delta"]
|
|
|
|
|
product.recompute_purchase_value()
|
|
|
|
|
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
|
2026-08-24 15:54:08 +03:30
|
|
|
StockMovement.objects.create(
|
|
|
|
|
product=product,
|
2026-09-04 13:05:54 +03:30
|
|
|
delta=serializer.validated_data["delta"],
|
2026-08-24 15:54:08 +03:30
|
|
|
quantity_after=product.quantity,
|
|
|
|
|
note=serializer.validated_data.get("note", ""),
|
|
|
|
|
)
|
2026-09-04 13:05:54 +03:30
|
|
|
return Response(ProductSerializer(product).data)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductMovementsView(generics.ListAPIView):
|
|
|
|
|
serializer_class = StockMovementSerializer
|
2026-09-04 13:05:54 +03:30
|
|
|
permission_classes = [IsAuthenticated]
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
def get_queryset(self):
|
|
|
|
|
return StockMovement.objects.filter(
|
2026-09-04 13:05:54 +03:30
|
|
|
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,
|
|
|
|
|
}
|
2026-08-24 15:54:08 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
# ---------------- فاکتور ----------------
|
|
|
|
|
|
2026-08-24 15:54:08 +03:30
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
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)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
)
|