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()
|
||||
Reference in New Issue
Block a user