Files
shopship/apps/bizapps/api/services.py
T
2026-08-31 15:11:52 +03:30

413 lines
18 KiB
Python

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()