adding
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.account.models import User
|
||||
from apps.banker.models import Contact
|
||||
from apps.bizapps.models import (
|
||||
Customer,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
LedgerEntry,
|
||||
LedgerInstallment,
|
||||
Product,
|
||||
StockMovement,
|
||||
Transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts
|
||||
# جدا، چون این اپ برای کسبوکارهای خرد است نه حسابداری شرکتی کامل).
|
||||
ACCOUNT_CASH = "1000" # صندوق
|
||||
ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخهی آتی)
|
||||
ACCOUNT_SALES = "4000" # فروش
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
@staticmethod
|
||||
def _next_sequenced_number(user: User, model, field_name: str, prefix: str) -> str:
|
||||
"""فرمت PREFIX-YYMM-NNNN با سکانسِ ماهانهی جداگانه برای هر کاربر —
|
||||
سطر آخرِ همان ماه را قفل میکند (select_for_update) تا دو درخواستِ
|
||||
همزمان شمارهی تکراری نگیرند."""
|
||||
now = timezone.now()
|
||||
month_prefix = f"{prefix}-{now:%y%m}-"
|
||||
last = (
|
||||
model.objects.select_for_update()
|
||||
.filter(user=user, **{f"{field_name}__startswith": month_prefix})
|
||||
.order_by(f"-{field_name}")
|
||||
.first()
|
||||
)
|
||||
if last:
|
||||
last_seq = int(getattr(last, field_name).rsplit("-", 1)[-1])
|
||||
else:
|
||||
last_seq = 0
|
||||
return f"{month_prefix}{last_seq + 1:04d}"
|
||||
|
||||
@staticmethod
|
||||
def _upsert_customer_contact(user: User, phone: str, name: str) -> Contact | None:
|
||||
phone = (phone or "").strip()
|
||||
if not phone:
|
||||
return None
|
||||
contact = Contact.objects.filter(user=user, phone=phone).first()
|
||||
if contact:
|
||||
return contact
|
||||
return Contact.objects.create(id=str(uuid.uuid4()), user=user, name=name or phone, phone=phone)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_invoice(user: User, data: dict, send_sms: bool = False) -> Invoice:
|
||||
items_data = data["items"]
|
||||
|
||||
invoice_number = InvoiceService._next_sequenced_number(user, Invoice, "invoice_number", "INV")
|
||||
customer: Customer | None = data.get("customer")
|
||||
payment_type = data.get("payment_type", Invoice.PaymentType.CASH)
|
||||
order_status = (
|
||||
Invoice.OrderStatus.PENDING_PAYMENT
|
||||
if payment_type == Invoice.PaymentType.CREDIT
|
||||
else Invoice.OrderStatus.READY_TO_SHIP
|
||||
)
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=user,
|
||||
invoice_number=invoice_number,
|
||||
invoice_date=data.get("invoice_date") or timezone.now(),
|
||||
customer=customer,
|
||||
buyer_name=data.get("buyer_name", "") or (customer.name if customer else ""),
|
||||
buyer_phone=data.get("buyer_phone", "") or (customer.phone if customer else ""),
|
||||
buyer_address=data.get("buyer_address", "") or (customer.address if customer else ""),
|
||||
seller_name=data.get("seller_name", ""),
|
||||
seller_phone=data.get("seller_phone", ""),
|
||||
seller_address=data.get("seller_address", ""),
|
||||
discount_amount=data.get("discount_amount", 0),
|
||||
tax_lines=data.get("tax_lines", []),
|
||||
payment_type=payment_type,
|
||||
shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP),
|
||||
order_status=order_status,
|
||||
tracking_code=data.get("tracking_code", ""),
|
||||
notes=data.get("notes", ""),
|
||||
)
|
||||
|
||||
subtotal = 0.0
|
||||
for item_data in items_data:
|
||||
product: Product | None = item_data.get("product")
|
||||
quantity = item_data["quantity"]
|
||||
# طبق سند: قیمت واحد در زمان فروش از SellingPrice کالا کپی میشود
|
||||
# (سمت کلاینت قابلدستکاری نیست) — فقط برای ردیفهای آزاد (بدون
|
||||
# کالا) قیمتِ ارسالی کلاینت بهعنوان قیمت واحد پذیرفته میشود.
|
||||
unit_price = product.unit_price if product else item_data["unit_price"]
|
||||
description = item_data.get("description") or (product.name if product else "")
|
||||
|
||||
item = InvoiceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
invoice=invoice,
|
||||
product=product,
|
||||
description=description,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
discount_percent=item_data.get("discount_percent", 0),
|
||||
)
|
||||
item.compute_total()
|
||||
item.save()
|
||||
subtotal += item.total_price
|
||||
|
||||
if product is not None:
|
||||
product = Product.objects.select_for_update().get(pk=product.pk)
|
||||
product.quantity -= quantity
|
||||
product.recompute_purchase_value()
|
||||
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
|
||||
StockMovement.objects.create(
|
||||
product=product,
|
||||
delta=-quantity,
|
||||
quantity_after=product.quantity,
|
||||
note=f"فروش فاکتور {invoice_number}",
|
||||
)
|
||||
|
||||
tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines)
|
||||
invoice.total_amount = subtotal
|
||||
invoice.final_amount = subtotal - invoice.discount_amount + tax_amount
|
||||
invoice.save(update_fields=["total_amount", "final_amount"])
|
||||
|
||||
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
|
||||
is_credit = payment_type == Invoice.PaymentType.CREDIT
|
||||
Transaction.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=user,
|
||||
transaction_number=transaction_number,
|
||||
transaction_date=invoice.invoice_date,
|
||||
reference_invoice=invoice,
|
||||
transaction_type=(
|
||||
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
|
||||
),
|
||||
debit_account=ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH,
|
||||
credit_account=ACCOUNT_SALES,
|
||||
amount=invoice.final_amount,
|
||||
description=f"فروش فاکتور {invoice_number}",
|
||||
)
|
||||
|
||||
if customer is not None:
|
||||
customer = Customer.objects.select_for_update().get(pk=customer.pk)
|
||||
customer.total_purchases_count += 1
|
||||
customer.total_purchase_amount += invoice.final_amount
|
||||
customer.last_purchase_date = invoice.invoice_date
|
||||
customer.save(
|
||||
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
|
||||
)
|
||||
|
||||
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه بهعنوان
|
||||
# یک ردیفِ بدهیدار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
|
||||
# مثالِ «۱۰۰ تومن فروش قسطی از نظر عادی درآمد، از نظر حرفهای بدهکاریه».
|
||||
if is_credit:
|
||||
LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title=f"فاکتور {invoice_number}",
|
||||
amount=invoice.final_amount,
|
||||
category=LedgerEntry.Category.SALE_CREDIT,
|
||||
entry_date=invoice.invoice_date.date(),
|
||||
total_amount=invoice.final_amount,
|
||||
counterparty_name=invoice.buyer_name,
|
||||
counterparty_phone=invoice.buyer_phone,
|
||||
linked_invoice=invoice,
|
||||
)
|
||||
if invoice.buyer_phone:
|
||||
LedgerService.recompute_customer_balance(user, invoice.buyer_phone)
|
||||
|
||||
if send_sms and invoice.buyer_phone:
|
||||
InvoiceService._send_invoice_sms(user, invoice)
|
||||
|
||||
logger.info(
|
||||
"invoice created: user=%s invoice=%s final_amount=%s transaction=%s",
|
||||
user.phone,
|
||||
invoice_number,
|
||||
invoice.final_amount,
|
||||
transaction_number,
|
||||
)
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
def _send_invoice_sms(user: User, invoice: Invoice) -> None:
|
||||
# ایمپورت دیرهنگام برای جلوگیری از وابستگی چرخهای در import-time
|
||||
# بین اپهای bizapps و messaging.
|
||||
from apps.messaging.api.services import BulkMessageService
|
||||
from apps.messaging.models import Channel
|
||||
|
||||
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
|
||||
if contact is None:
|
||||
return
|
||||
body = f"فاکتور {invoice.invoice_number} به مبلغ {invoice.final_amount:,.0f} تومان ثبت شد."
|
||||
BulkMessageService.create_and_dispatch(
|
||||
user=user,
|
||||
channels=[Channel.SMS],
|
||||
contact_ids=[contact.id],
|
||||
body_override=body,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def cancel_invoice(user: User, invoice_id: str) -> Invoice:
|
||||
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
|
||||
if invoice.order_status == Invoice.OrderStatus.CANCELLED:
|
||||
raise ValueError("این فاکتور قبلا لغو شده است.")
|
||||
|
||||
for item in invoice.items.select_related("product"):
|
||||
if item.product is None:
|
||||
continue
|
||||
product = Product.objects.select_for_update().get(pk=item.product_id)
|
||||
product.quantity += item.quantity
|
||||
product.recompute_purchase_value()
|
||||
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
|
||||
StockMovement.objects.create(
|
||||
product=product,
|
||||
delta=item.quantity,
|
||||
quantity_after=product.quantity,
|
||||
note=f"لغو فاکتور {invoice.invoice_number}",
|
||||
)
|
||||
|
||||
# ردیفِ دفترِ بدهیدارِ متصل به این فاکتور: اگر هنوز هیچ قسطی روی آن
|
||||
# ثبت نشده، با لغوِ فاکتور آن بدهی هم دیگر معنی ندارد — حذفش میکنیم.
|
||||
# اگر قسطی ثبت شده (یعنی پولی واقعا رد و بدل شده)، تاریخچهی مالی را
|
||||
# پاک نمیکنیم؛ همانجا برای رسیدگیِ دستیِ کاربر میماند.
|
||||
linked_entry = invoice.ledger_entries.first()
|
||||
if linked_entry is not None and not linked_entry.installments.exists():
|
||||
phone = linked_entry.counterparty_phone
|
||||
linked_entry.delete()
|
||||
if phone:
|
||||
LedgerService.recompute_customer_balance(user, phone)
|
||||
|
||||
original = invoice.transactions.order_by("created_at").first()
|
||||
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
|
||||
Transaction.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=user,
|
||||
transaction_number=transaction_number,
|
||||
transaction_date=timezone.now(),
|
||||
reference_invoice=invoice,
|
||||
transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE,
|
||||
debit_account=original.credit_account if original else ACCOUNT_SALES,
|
||||
credit_account=original.debit_account if original else ACCOUNT_CASH,
|
||||
amount=-(original.amount if original else invoice.final_amount),
|
||||
description=f"لغو فاکتور {invoice.invoice_number}",
|
||||
)
|
||||
|
||||
invoice.order_status = Invoice.OrderStatus.CANCELLED
|
||||
invoice.save(update_fields=["order_status", "updated_at"])
|
||||
logger.info(
|
||||
"invoice cancelled: user=%s invoice=%s reversal_transaction=%s",
|
||||
user.phone,
|
||||
invoice.invoice_number,
|
||||
transaction_number,
|
||||
)
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def update_status(
|
||||
user: User,
|
||||
invoice_id: str,
|
||||
order_status: str | None = None,
|
||||
payment_status: str | None = None,
|
||||
tracking_code: str | None = None,
|
||||
) -> Invoice:
|
||||
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
|
||||
update_fields = ["updated_at"]
|
||||
|
||||
status_changed = order_status is not None and order_status != invoice.order_status
|
||||
if order_status is not None:
|
||||
invoice.order_status = order_status
|
||||
update_fields.append("order_status")
|
||||
if payment_status is not None:
|
||||
invoice.payment_status = payment_status
|
||||
if payment_status == Invoice.PaymentStatus.PAID and invoice.paid_at is None:
|
||||
invoice.paid_at = timezone.now()
|
||||
update_fields.append("paid_at")
|
||||
update_fields.append("payment_status")
|
||||
if tracking_code is not None:
|
||||
invoice.tracking_code = tracking_code
|
||||
update_fields.append("tracking_code")
|
||||
|
||||
invoice.save(update_fields=update_fields)
|
||||
logger.info(
|
||||
"invoice status updated: user=%s invoice=%s order_status=%s payment_status=%s",
|
||||
user.phone,
|
||||
invoice.invoice_number,
|
||||
invoice.order_status,
|
||||
invoice.payment_status,
|
||||
)
|
||||
|
||||
# اگر فاکتور به «پرداختشده» رفت و به یک ردیفِ بدهیدار وصل بود، همان
|
||||
# مانده را بهعنوان یک قسطِ تسویهی کامل ثبت میکنیم — کاربر مجبور
|
||||
# نیست دستی برود توی دفتر و قسط بزند.
|
||||
if payment_status == Invoice.PaymentStatus.PAID:
|
||||
linked_entry = invoice.ledger_entries.first()
|
||||
if linked_entry is not None and linked_entry.remaining_amount > 0:
|
||||
LedgerInstallment.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
ledger_entry=linked_entry,
|
||||
amount=linked_entry.remaining_amount,
|
||||
paid_date=timezone.now().date(),
|
||||
reference=f"تسویهی کامل فاکتور {invoice.invoice_number}",
|
||||
)
|
||||
if linked_entry.counterparty_phone:
|
||||
LedgerService.recompute_customer_balance(user, linked_entry.counterparty_phone)
|
||||
|
||||
# طبق سند: «پس از هر تغییر وضعیت سفارش، یک پیامک ارسال شود» — برخلاف
|
||||
# پیامکِ ثبت فاکتور (که opt-in است)، این یکی خودکار است، چون همان
|
||||
# رفتاریست که apps.orders از قبل برای مراحل پیگیری سفارش دارد و
|
||||
# کاربر با آن آشناست.
|
||||
if status_changed and invoice.buyer_phone:
|
||||
InvoiceService._send_status_sms(user, invoice)
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
def _send_status_sms(user: User, invoice: Invoice) -> None:
|
||||
from apps.messaging.api.services import BulkMessageService
|
||||
from apps.messaging.models import Channel
|
||||
|
||||
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
|
||||
if contact is None:
|
||||
return
|
||||
status_label = Invoice.OrderStatus(invoice.order_status).label
|
||||
body = f"وضعیت سفارش فاکتور {invoice.invoice_number}: {status_label}"
|
||||
BulkMessageService.create_and_dispatch(
|
||||
user=user,
|
||||
channels=[Channel.SMS],
|
||||
contact_ids=[contact.id],
|
||||
body_override=body,
|
||||
)
|
||||
|
||||
|
||||
class LedgerService:
|
||||
"""هماهنگیِ لایهی «عادی» (دستهبندی ساده) با لایهی «حرفهای» (بدهکاری/
|
||||
بستانکاری واقعی) — طبق درخواست: کاربرِ معمولی فقط دسته را انتخاب میکند،
|
||||
این سرویس تضمین میکند ماندهی بدهیِ هر مشتری (Customer.outstanding_balance)
|
||||
همیشه با جمعِ واقعیِ ردیفهای «فروش قسطی»ِ همان شمارهتماس همخوان بماند."""
|
||||
|
||||
@staticmethod
|
||||
def recompute_customer_balance(user: User, phone: str) -> None:
|
||||
phone = (phone or "").strip()
|
||||
if not phone:
|
||||
return
|
||||
customer = Customer.objects.filter(user=user, phone=phone).first()
|
||||
if customer is None:
|
||||
return
|
||||
receivables = LedgerEntry.objects.filter(
|
||||
user=user, category=LedgerEntry.Category.SALE_CREDIT, counterparty_phone=phone
|
||||
).prefetch_related("installments")
|
||||
balance = sum(e.remaining_amount for e in receivables)
|
||||
if customer.outstanding_balance != balance:
|
||||
customer.outstanding_balance = balance
|
||||
customer.save(update_fields=["outstanding_balance", "updated_at"])
|
||||
|
||||
@staticmethod
|
||||
def export_csv(user: User, start=None, end=None) -> str:
|
||||
"""خروجیِ CSV حرفهای — UTF-8 BOM برای بازشدنِ درستِ فارسی در اکسل."""
|
||||
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
|
||||
if start:
|
||||
qs = qs.filter(entry_date__gte=start)
|
||||
if end:
|
||||
qs = qs.filter(entry_date__lte=end)
|
||||
|
||||
buf = io.StringIO()
|
||||
buf.write("") # BOM
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(
|
||||
[
|
||||
"تاریخ",
|
||||
"نوع",
|
||||
"دسته",
|
||||
"عنوان",
|
||||
"مبلغ",
|
||||
"طرف حساب",
|
||||
"شماره طرف حساب",
|
||||
"مبلغ کل تعهد",
|
||||
"پرداختشده",
|
||||
"مانده",
|
||||
"یادداشت",
|
||||
]
|
||||
)
|
||||
for e in qs:
|
||||
writer.writerow(
|
||||
[
|
||||
e.entry_date.isoformat(),
|
||||
e.get_entry_type_display(),
|
||||
e.get_category_display() if e.category else "",
|
||||
e.title,
|
||||
f"{e.amount:.0f}",
|
||||
e.counterparty_name,
|
||||
e.counterparty_phone,
|
||||
f"{e.total_amount:.0f}" if e.is_debt else "",
|
||||
f"{e.paid_amount:.0f}" if e.is_debt else "",
|
||||
f"{e.remaining_amount:.0f}" if e.is_debt else "",
|
||||
e.note,
|
||||
]
|
||||
)
|
||||
return buf.getvalue()
|
||||
@@ -1,92 +0,0 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-23 11:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Appointment',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('customer_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
|
||||
('customer_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
|
||||
('title', models.CharField(max_length=150, verbose_name='عنوان/خدمت')),
|
||||
('start_at', models.DateTimeField(verbose_name='زمان نوبت')),
|
||||
('status', models.CharField(choices=[('scheduled', 'زمان\u200cبندی\u200cشده'), ('done', 'انجام\u200cشده'), ('cancelled', 'لغوشده')], default='scheduled', max_length=10)),
|
||||
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appointments', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'نوبت',
|
||||
'verbose_name_plural': 'نوبت\u200cدهی',
|
||||
'ordering': ['start_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=150, verbose_name='نام')),
|
||||
('phone', models.CharField(blank=True, max_length=20, verbose_name='شماره تماس')),
|
||||
('email', models.CharField(blank=True, max_length=150, verbose_name='ایمیل')),
|
||||
('address', models.CharField(blank=True, max_length=255, verbose_name='آدرس')),
|
||||
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'مشتری',
|
||||
'verbose_name_plural': 'مشتریان',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LedgerEntry',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('entry_type', models.CharField(choices=[('income', 'درآمد'), ('expense', 'هزینه')], max_length=10, verbose_name='نوع')),
|
||||
('title', models.CharField(max_length=150, verbose_name='عنوان')),
|
||||
('amount', models.FloatField(verbose_name='مبلغ')),
|
||||
('category', models.CharField(blank=True, max_length=100, verbose_name='دسته')),
|
||||
('entry_date', models.DateField(verbose_name='تاریخ')),
|
||||
('note', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_entries', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ردیف دفتر درآمد/هزینه',
|
||||
'verbose_name_plural': 'دفتر درآمد و هزینه',
|
||||
'ordering': ['-entry_date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=150, verbose_name='نام کالا')),
|
||||
('sku', models.CharField(blank=True, max_length=100, verbose_name='کد کالا')),
|
||||
('quantity', models.FloatField(default=0, verbose_name='موجودی')),
|
||||
('unit_price', models.FloatField(default=0, verbose_name='قیمت واحد')),
|
||||
('low_stock_threshold', models.FloatField(default=0, verbose_name='آستانه\u200cی هشدار موجودی کم')),
|
||||
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'کالا',
|
||||
'verbose_name_plural': 'انبار',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-23 12:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bizapps', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BrokerageTransaction',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('client_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
|
||||
('client_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
|
||||
('symbol', models.CharField(max_length=50, verbose_name='نماد')),
|
||||
('side', models.CharField(choices=[('buy', 'خرید'), ('sell', 'فروش')], max_length=4, verbose_name='نوع معامله')),
|
||||
('quantity', models.FloatField(verbose_name='تعداد')),
|
||||
('price', models.FloatField(verbose_name='قیمت واحد')),
|
||||
('commission_percent', models.FloatField(default=0, verbose_name='درصد کارمزد')),
|
||||
('transaction_date', models.DateField(verbose_name='تاریخ')),
|
||||
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='brokerage_transactions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'معامله\u200cی بورسی',
|
||||
'verbose_name_plural': 'کارگزاری',
|
||||
'ordering': ['-transaction_date'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-23 14:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bizapps', '0002_brokeragetransaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='BrokerageTransaction',
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-23 20:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bizapps', '0003_delete_brokeragetransaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='category',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='دسته'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockMovement',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('delta', models.FloatField(verbose_name='تغییر')),
|
||||
('quantity_after', models.FloatField(verbose_name='موجودی بعد از تغییر')),
|
||||
('note', models.CharField(blank=True, max_length=255, verbose_name='یادداشت')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='bizapps.product')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تغییر موجودی',
|
||||
'verbose_name_plural': 'تاریخچه\u200cی موجودی',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,296 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.account.models import User
|
||||
from apps.bizapps.api.services import InvoiceService, LedgerService
|
||||
from apps.bizapps.models import Customer, LedgerEntry, LedgerInstallment
|
||||
|
||||
# این ماژول لایهی «هماهنگیِ عادی/حرفهای»ِ دفتر درآمد و هزینه را پوشش میدهد —
|
||||
# دقیقا همان بخشی که کاربر صریحا خواسته «تستنویسی برایش خیلی مهم است»، چون
|
||||
# باگ اینجا یعنی ماندهی بدهیِ نمایشدادهشده به کاربر با واقعیت فرق کند.
|
||||
|
||||
|
||||
def _make_user(phone="09120000000") -> User:
|
||||
return User.objects.create_user(phone=phone, is_active=True, state=User.State.ACTIVE)
|
||||
|
||||
|
||||
class LedgerEntryModelTests(TestCase):
|
||||
"""دستهبندیِ هر ردیف باید دقیقا به نوعِ حرفهایِ درستش نگاشت شود — این
|
||||
همان «هماهنگی» است که کاربر مثال زد (فروش قسطی = طلب، نه درآمدِ صرف)."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = _make_user()
|
||||
|
||||
def _entry(self, **kwargs):
|
||||
defaults = dict(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="تست",
|
||||
amount=100,
|
||||
entry_date=date.today(),
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return LedgerEntry.objects.create(**defaults)
|
||||
|
||||
def test_sale_credit_is_receivable_debt(self):
|
||||
entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=100)
|
||||
self.assertTrue(entry.is_debt)
|
||||
self.assertEqual(entry.professional_type, "receivable")
|
||||
|
||||
def test_purchase_credit_is_payable_debt(self):
|
||||
entry = self._entry(
|
||||
entry_type=LedgerEntry.EntryType.EXPENSE,
|
||||
category=LedgerEntry.Category.PURCHASE_CREDIT,
|
||||
total_amount=100,
|
||||
)
|
||||
self.assertTrue(entry.is_debt)
|
||||
self.assertEqual(entry.professional_type, "payable")
|
||||
|
||||
def test_simple_categories_are_not_debt(self):
|
||||
for category in [
|
||||
LedgerEntry.Category.SALE_CASH,
|
||||
LedgerEntry.Category.SERVICE_INCOME,
|
||||
LedgerEntry.Category.OTHER_INCOME,
|
||||
]:
|
||||
entry = self._entry(category=category)
|
||||
self.assertFalse(entry.is_debt, category)
|
||||
self.assertEqual(entry.professional_type, "simple", category)
|
||||
|
||||
def test_remaining_amount_tracks_installments(self):
|
||||
entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=1000)
|
||||
self.assertEqual(entry.remaining_amount, 1000)
|
||||
LedgerInstallment.objects.create(
|
||||
id=str(uuid.uuid4()), ledger_entry=entry, amount=400, paid_date=date.today()
|
||||
)
|
||||
entry.refresh_from_db()
|
||||
self.assertEqual(entry.paid_amount, 400)
|
||||
self.assertEqual(entry.remaining_amount, 600)
|
||||
LedgerInstallment.objects.create(
|
||||
id=str(uuid.uuid4()), ledger_entry=entry, amount=600, paid_date=date.today()
|
||||
)
|
||||
entry.refresh_from_db()
|
||||
self.assertEqual(entry.remaining_amount, 0)
|
||||
|
||||
def test_remaining_amount_is_zero_for_non_debt_entries(self):
|
||||
entry = self._entry(category=LedgerEntry.Category.SALE_CASH)
|
||||
self.assertEqual(entry.remaining_amount, 0)
|
||||
|
||||
|
||||
class LedgerServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = _make_user()
|
||||
self.customer = Customer.objects.create(
|
||||
id=str(uuid.uuid4()), user=self.user, name="مشتری", phone="09121112222"
|
||||
)
|
||||
|
||||
def test_recompute_balance_sums_open_receivables(self):
|
||||
LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="فاکتور ۱",
|
||||
amount=500,
|
||||
category=LedgerEntry.Category.SALE_CREDIT,
|
||||
entry_date=date.today(),
|
||||
total_amount=500,
|
||||
counterparty_phone="09121112222",
|
||||
)
|
||||
LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="فاکتور ۲",
|
||||
amount=300,
|
||||
category=LedgerEntry.Category.SALE_CREDIT,
|
||||
entry_date=date.today(),
|
||||
total_amount=300,
|
||||
counterparty_phone="09121112222",
|
||||
)
|
||||
LedgerService.recompute_customer_balance(self.user, "09121112222")
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, 800)
|
||||
|
||||
def test_balance_drops_as_installments_are_paid(self):
|
||||
entry = LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="فاکتور",
|
||||
amount=1000,
|
||||
category=LedgerEntry.Category.SALE_CREDIT,
|
||||
entry_date=date.today(),
|
||||
total_amount=1000,
|
||||
counterparty_phone="09121112222",
|
||||
)
|
||||
LedgerService.recompute_customer_balance(self.user, "09121112222")
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, 1000)
|
||||
|
||||
LedgerInstallment.objects.create(
|
||||
id=str(uuid.uuid4()), ledger_entry=entry, amount=1000, paid_date=date.today()
|
||||
)
|
||||
LedgerService.recompute_customer_balance(self.user, "09121112222")
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, 0)
|
||||
|
||||
def test_ignores_other_users_entries(self):
|
||||
other = _make_user(phone="09129998888")
|
||||
LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=other,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="فاکتور مالِ یکی دیگه",
|
||||
amount=999,
|
||||
category=LedgerEntry.Category.SALE_CREDIT,
|
||||
entry_date=date.today(),
|
||||
total_amount=999,
|
||||
counterparty_phone="09121112222",
|
||||
)
|
||||
LedgerService.recompute_customer_balance(self.user, "09121112222")
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, 0)
|
||||
|
||||
def test_export_csv_filters_by_date_range(self):
|
||||
LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="داخل بازه",
|
||||
amount=100,
|
||||
category=LedgerEntry.Category.SALE_CASH,
|
||||
entry_date=date(2026, 1, 15),
|
||||
)
|
||||
LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.EXPENSE,
|
||||
title="خارج از بازه",
|
||||
amount=50,
|
||||
category=LedgerEntry.Category.RENT,
|
||||
entry_date=date(2026, 3, 1),
|
||||
)
|
||||
csv_text = LedgerService.export_csv(self.user, start=date(2026, 1, 1), end=date(2026, 1, 31))
|
||||
self.assertIn("داخل بازه", csv_text)
|
||||
self.assertNotIn("خارج از بازه", csv_text)
|
||||
|
||||
|
||||
class InvoiceLedgerIntegrationTests(TestCase):
|
||||
"""اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک
|
||||
ردیفِ بدهیدار در دفتر بسازد؛ فروشِ نقدی نباید دفتر را شلوغ کند."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = _make_user()
|
||||
self.customer = Customer.objects.create(
|
||||
id=str(uuid.uuid4()), user=self.user, name="مشتری قسطی", phone="09123334444"
|
||||
)
|
||||
|
||||
def _invoice_data(self, payment_type):
|
||||
return {
|
||||
"customer": self.customer,
|
||||
"buyer_name": self.customer.name,
|
||||
"buyer_phone": self.customer.phone,
|
||||
"buyer_address": "",
|
||||
"items": [
|
||||
{"product": None, "description": "کالای آزاد", "quantity": 1, "unit_price": 100, "discount_percent": 0}
|
||||
],
|
||||
"payment_type": payment_type,
|
||||
}
|
||||
|
||||
def test_credit_invoice_creates_linked_receivable_entry(self):
|
||||
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
|
||||
entry = LedgerEntry.objects.get(linked_invoice=invoice)
|
||||
self.assertEqual(entry.category, LedgerEntry.Category.SALE_CREDIT)
|
||||
self.assertEqual(entry.total_amount, invoice.final_amount)
|
||||
self.assertEqual(entry.remaining_amount, invoice.final_amount)
|
||||
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, invoice.final_amount)
|
||||
|
||||
def test_cash_invoice_does_not_create_ledger_entry(self):
|
||||
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("cash"))
|
||||
self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
|
||||
|
||||
def test_marking_invoice_paid_settles_the_linked_entry(self):
|
||||
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
|
||||
InvoiceService.update_status(self.user, invoice.id, payment_status="paid")
|
||||
|
||||
entry = LedgerEntry.objects.get(linked_invoice=invoice)
|
||||
self.assertEqual(entry.remaining_amount, 0)
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, 0)
|
||||
|
||||
def test_cancelling_unpaid_credit_invoice_removes_the_entry(self):
|
||||
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
|
||||
InvoiceService.cancel_invoice(self.user, invoice.id)
|
||||
|
||||
self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.outstanding_balance, 0)
|
||||
|
||||
def test_cancelling_partially_paid_credit_invoice_keeps_the_entry(self):
|
||||
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
|
||||
entry = LedgerEntry.objects.get(linked_invoice=invoice)
|
||||
LedgerInstallment.objects.create(
|
||||
id=str(uuid.uuid4()), ledger_entry=entry, amount=50, paid_date=date.today()
|
||||
)
|
||||
|
||||
invoice.order_status = invoice.OrderStatus.PENDING_PAYMENT
|
||||
invoice.save(update_fields=["order_status"])
|
||||
InvoiceService.cancel_invoice(self.user, invoice.id)
|
||||
|
||||
# قسطی که واقعا پرداخت شده نباید محو شود — تاریخچهی مالی دستنخورده میماند.
|
||||
self.assertTrue(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
|
||||
|
||||
|
||||
class LedgerApiTests(TestCase):
|
||||
"""چند نقطهی حیاتیِ سطح API — اعتبارسنجی اضافهشدنِ قسطِ بیش از حد،
|
||||
و اینکه خروجیِ CSV واقعا با content-type درست برمیگردد."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = _make_user()
|
||||
self.client = APIClient()
|
||||
token = str(RefreshToken.for_user(self.user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
def test_create_debt_entry_without_total_amount_is_rejected(self):
|
||||
response = self.client.post(
|
||||
reverse("bizapps:ledger-list"),
|
||||
{
|
||||
"entry_type": "income",
|
||||
"title": "فروش قسطی بدون مبلغ کل",
|
||||
"amount": 100,
|
||||
"category": "sale_credit",
|
||||
"entry_date": date.today().isoformat(),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_installment_exceeding_remaining_amount_is_rejected(self):
|
||||
entry = LedgerEntry.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=self.user,
|
||||
entry_type=LedgerEntry.EntryType.INCOME,
|
||||
title="فروش قسطی",
|
||||
amount=1000,
|
||||
category=LedgerEntry.Category.SALE_CREDIT,
|
||||
entry_date=date.today(),
|
||||
total_amount=1000,
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": entry.id}),
|
||||
{"amount": 5000, "paid_date": date.today().isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_export_returns_csv(self):
|
||||
response = self.client.get(reverse("bizapps:ledger-export"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("text/csv", response["Content-Type"])
|
||||
Reference in New Issue
Block a user