Files
shopship/apps/bizapps/api/serializers.py
T
mobiniqow 831e656fa3 Fix OTP/SMS, ledger accounting, order tracking, and add reviews app
- Add MessageTemplate.runtime_field_labels so mobile's per-field
  execution-time labels are actually persisted (were silently dropped)
- Fix AdvanceOrderStageView reading the wrong extra-input key, and be
  honest about SMS send failures instead of always claiming success
- Add admin gift-gem SMS notification via Kavenegar
- Post real double-entry Transactions for plain (non-invoice) ledger
  entries so trial balance, the معین screen, and CSV/XLSX exports
  actually include everyday income/expense entries; backfill existing
  historical entries via data migration
- Rewrite CSV ledger export to produce an actual دفتر معین structure
  (per-account running balance) instead of a flat row dump
- Add invoice percent/promotional discounts, tax presets, ledger
  discount-percent handling, product SKU/category/expiry fields, and
  manual ledger-book archiving (replacing auto-archive on new book)
- Add reviews app (models, API, admin) for the comments/reviews feature
- Update tests for the above; fix two stale assertions left over from
  earlier work in this branch (معین sheet name, book auto-close)
2026-09-15 23:18:05 +03:30

274 lines
13 KiB
Python

from rest_framework import serializers
from apps.bizapps.models import (
Account,
Customer,
Invoice,
InvoiceItem,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
class AccountSerializer(serializers.ModelSerializer):
full_path = serializers.CharField(read_only=True)
level_display = serializers.CharField(source="get_level_display", read_only=True)
account_type_display = serializers.CharField(source="get_account_type_display", read_only=True)
class Meta:
model = Account
fields = [
"id", "code", "name", "level", "level_display", "account_type",
"account_type_display", "parent", "full_path", "is_system", "is_active",
]
read_only_fields = ["is_system"]
class CustomerSerializer(serializers.ModelSerializer):
account_code = serializers.CharField(source="account.code", read_only=True, default="")
class Meta:
model = Customer
fields = [
"id", "name", "phone", "social_platform", "social_handle",
"email", "address", "notes",
"total_purchases_count", "total_purchase_amount", "last_purchase_date",
"outstanding_balance", "account_code", "updated_at",
]
# این‌ها را سرویس‌ها محاسبه می‌کنند، نه کلاینت — وگرنه مانده‌ی بدهی
# از سمت اپ قابل‌دستکاری می‌شد.
read_only_fields = [
"total_purchases_count", "total_purchase_amount", "last_purchase_date",
"outstanding_balance", "account_code", "updated_at",
]
class LedgerInstallmentSerializer(serializers.ModelSerializer):
class Meta:
model = LedgerInstallment
fields = ["id", "amount", "paid_date", "reference", "created_at"]
# برخلافِ بقیه‌ی مدل‌ها (که UUID را خودِ اپ فلاتر می‌سازد و می‌فرستد)،
# id قسط را ویو تولید می‌کند — پس نباید از کلاینت الزامی باشد.
read_only_fields = ["id", "created_at"]
def validate_amount(self, value):
if value <= 0:
raise serializers.ValidationError("مبلغ قسط باید بزرگ‌تر از صفر باشد.")
return value
def validate(self, attrs):
entry: LedgerEntry = self.context["ledger_entry"]
if not entry.is_debt:
raise serializers.ValidationError("فقط برای ردیف‌های بدهی‌دار می‌توان قسط ثبت کرد.")
amount = attrs.get("amount", 0)
# epsilon کوچک تا خطای گرد‌کردنِ اعشارِ شناور باعثِ ردِ یک قسطِ
# تسویه‌ی دقیق نشود.
if amount > entry.remaining_amount + 1:
raise serializers.ValidationError("مبلغ قسط از مانده‌ی بدهی بیشتر است.")
return attrs
class LedgerEntrySerializer(serializers.ModelSerializer):
installments = LedgerInstallmentSerializer(many=True, read_only=True)
is_debt = serializers.BooleanField(read_only=True)
professional_type = serializers.CharField(read_only=True)
paid_amount = serializers.FloatField(read_only=True)
remaining_amount = serializers.FloatField(read_only=True)
category_display = serializers.CharField(source="get_category_display", read_only=True)
account_name = serializers.CharField(source="account.name", read_only=True, default="")
account_code = serializers.CharField(source="account.code", read_only=True, default="")
discount_amount = serializers.FloatField(read_only=True)
tax_amount = serializers.FloatField(read_only=True)
computed_amount = serializers.FloatField(read_only=True)
class Meta:
model = LedgerEntry
fields = [
"id", "entry_type", "title", "amount", "category", "category_display",
"entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone",
"linked_invoice", "account_name", "account_code",
"base_amount", "discount_percent", "tax_percent",
"discount_amount", "tax_amount", "computed_amount",
"is_debt", "professional_type", "paid_amount", "remaining_amount",
"installments", "updated_at",
]
read_only_fields = ["linked_invoice", "account_name", "account_code", "updated_at"]
def validate(self, attrs):
category = attrs.get("category", getattr(self.instance, "category", ""))
if category in LedgerEntry.DEBT_CATEGORIES:
total = attrs.get("total_amount", getattr(self.instance, "total_amount", 0))
if not total or total <= 0:
raise serializers.ValidationError(
{"total_amount": "برای فروش قسطی/خرید نسیه، مبلغ کل تعهد الزامی است."}
)
return attrs
class ProductSerializer(serializers.ModelSerializer):
is_low_stock = serializers.BooleanField(read_only=True)
class Meta:
model = Product
fields = [
"id", "name", "sku", "category", "unit", "quantity",
"unit_price", "purchase_price", "total_purchase_value",
"low_stock_threshold", "is_low_stock", "is_active",
"production_date", "expiry_date", "notes", "updated_at",
]
read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"]
class StockMovementSerializer(serializers.ModelSerializer):
class Meta:
model = StockMovement
fields = ["id", "delta", "quantity_after", "note", "created_at"]
read_only_fields = fields
class AdjustStockSerializer(serializers.Serializer):
delta = serializers.FloatField()
note = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
def validate_delta(self, value):
if value == 0:
raise serializers.ValidationError("مقدار تغییر نمی‌تواند صفر باشد.")
return value
class InvoiceItemSerializer(serializers.ModelSerializer):
class Meta:
model = InvoiceItem
fields = ["id", "product", "description", "quantity", "unit_price", "discount_percent", "total_price"]
read_only_fields = ["total_price"]
class InvoiceSerializer(serializers.ModelSerializer):
items = InvoiceItemSerializer(many=True, read_only=True)
payment_type_display = serializers.CharField(source="get_payment_type_display", read_only=True)
payment_status_display = serializers.CharField(source="get_payment_status_display", read_only=True)
order_status_display = serializers.CharField(source="get_order_status_display", read_only=True)
class Meta:
model = Invoice
fields = [
"id", "invoice_number", "invoice_date", "customer",
"buyer_name", "buyer_phone", "buyer_address",
"seller_name", "seller_phone", "seller_address",
"total_amount", "discount_percent", "discount_amount",
"extra_discount_label", "extra_discount_percent", "extra_discount_amount",
"tax_lines", "final_amount",
"payment_type", "payment_type_display",
"payment_status", "payment_status_display",
"order_status", "order_status_display",
"shipping_method", "tracking_code", "notes", "paid_at",
"items", "created_at", "updated_at",
]
read_only_fields = [
"invoice_number", "total_amount", "final_amount", "paid_at", "created_at", "updated_at",
]
class CreateInvoiceItemSerializer(serializers.Serializer):
product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), required=False, allow_null=True)
description = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
quantity = serializers.FloatField(min_value=0.0001)
unit_price = serializers.FloatField(required=False, default=0)
discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
def validate(self, attrs):
if attrs.get("product") is None and not attrs.get("description"):
raise serializers.ValidationError("برای ردیف بدون کالا، شرح الزامی است.")
return attrs
class CreateInvoiceSerializer(serializers.Serializer):
items = CreateInvoiceItemSerializer(many=True)
customer = serializers.PrimaryKeyRelatedField(queryset=Customer.objects.all(), required=False, allow_null=True)
invoice_date = serializers.DateTimeField(required=False)
buyer_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
buyer_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="")
buyer_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
seller_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
seller_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="")
seller_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
# درصد اولویت دارد: اگر discount_percent مثبت باشد، discount_amount از
# روی همان محاسبه می‌شود (هم‌الگو با تخفیفِ درصدیِ هر قلم فاکتور)؛
# discount_amount خامْ فقط برای سازگاری با کلاینت‌های قدیمی نگه داشته
# شده — اگر درصد صفر باشد همان مقدار مستقیم استفاده می‌شود.
discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
discount_amount = serializers.FloatField(required=False, default=0, min_value=0)
# تخفیفِ ویژه/دوره‌ای — جدا از تخفیفِ بالا، روی مبلغِ بعد از آن اعمال
# می‌شود. یا با درصد یا با مبلغِ ثابت پر می‌شود.
extra_discount_label = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
extra_discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
extra_discount_amount = serializers.FloatField(required=False, default=0, min_value=0)
tax_lines = serializers.ListField(child=serializers.DictField(), required=False, default=list)
payment_type = serializers.ChoiceField(choices=Invoice.PaymentType.choices, required=False)
shipping_method = serializers.ChoiceField(choices=Invoice.ShippingMethod.choices, required=False)
tracking_code = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
notes = serializers.CharField(required=False, allow_blank=True, default="")
send_sms = serializers.BooleanField(required=False, default=False)
def validate_items(self, value):
if not value:
raise serializers.ValidationError("فاکتور باید حداقل یک قلم کالا داشته باشد.")
return value
class UpdateInvoiceStatusSerializer(serializers.Serializer):
order_status = serializers.ChoiceField(choices=Invoice.OrderStatus.choices, required=False)
payment_status = serializers.ChoiceField(choices=Invoice.PaymentStatus.choices, required=False)
tracking_code = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError("حداقل یکی از فیلدهای وضعیت باید ارسال شود.")
return attrs
class TransactionSerializer(serializers.ModelSerializer):
transaction_type_display = serializers.CharField(source="get_transaction_type_display", read_only=True)
debit_account_name = serializers.CharField(source="debit_account_ref.name", read_only=True, default="")
credit_account_name = serializers.CharField(source="credit_account_ref.name", read_only=True, default="")
class Meta:
model = Transaction
fields = [
"id", "transaction_number", "transaction_date", "reference_invoice",
"transaction_type", "transaction_type_display",
"debit_account", "credit_account", "debit_account_name", "credit_account_name",
"amount", "description", "created_at",
]
read_only_fields = fields
class LedgerBookSerializer(serializers.ModelSerializer):
class Meta:
model = LedgerBook
fields = [
"id", "name", "start_date", "end_date", "opening_balance",
"is_active", "is_closed", "closed_at", "created_at",
]
read_only_fields = ["id", "is_active", "is_closed", "closed_at", "created_at"]
class OpenLedgerBookSerializer(serializers.Serializer):
name = serializers.CharField(max_length=120)
start_date = serializers.DateField()
end_date = serializers.DateField(required=False, allow_null=True)
# پیش‌فرض true: انتقالِ مانده سرِ سال مالی کارِ استاندارد است.
carry_balance = serializers.BooleanField(required=False, default=True)
def validate(self, attrs):
end = attrs.get("end_date")
if end and end <= attrs["start_date"]:
raise serializers.ValidationError(
{"end_date": "تاریخ پایان باید بعد از تاریخ شروع باشد."}
)
return attrs