831e656fa3
- 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)
274 lines
13 KiB
Python
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
|