Files
2026-08-16 15:42:48 +03:30

207 lines
9.2 KiB
Python

# services/transfer_services.py
import logging
from typing import Optional
from django.db import transaction
from django.core.exceptions import ValidationError, PermissionDenied
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.db.models import Sum, QuerySet
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from .models import StoreTransferRequest, ProductInStore, StoreRoom, Product
# تنظیم لابر استاندارد برای مانیتورینگ سازمانی
logger = logging.getLogger(__name__)
User = get_user_model()
def _check_target_store_permission(store: StoreRoom, user: AbstractBaseUser) -> None:
"""بررسی دسترسی کاربر به انبار مقصد"""
if not (user.is_superuser or store.user.filter(id=user.id).exists()):
logger.warning(
"Unauthorized transfer approval attempt",
extra={"user_id": user.id, "store_id": store.id}
)
raise PermissionDenied(_("شما دسترسی و مالکیت لازم برای مدیریت ورود کالا به این انبار را ندارید."))
def _check_source_store_permission(store: StoreRoom, user: AbstractBaseUser) -> None:
"""بررسی دسترسی کاربر به انبار مبدا"""
if not (user.is_superuser or store.user.filter(id=user.id).exists()):
logger.warning(
"Unauthorized transfer request attempt",
extra={"user_id": user.id, "store_id": store.id}
)
raise PermissionDenied(_("شما دسترسی لازم برای مدیریت خروج کالا از این انبار را ندارید."))
def get_product_current_stock(store: StoreRoom, product: Product, lock_for_update: bool = False) -> int:
"""
محاسبه موجودی لحظه‌ای یک کالا در انبار مشخص.
پارامتر lock_for_update در زمان تایید تراکنش فعال می‌شود تا از Race Condition در موجودی جلوگیری کند.
"""
qs = ProductInStore.objects.filter(StoreRoom=store, Product=product)
# قفل کردن رکوردهای موجودی انبار برای جلوگیری از کسر همزمان توسط چند تراکنش
if lock_for_update:
qs = qs.select_for_update()
totals = qs.aggregate(stock=Sum('s_in') - Sum('s_out'))
return totals['stock'] or 0
@transaction.atomic
def create_transfer_request(
*,
source_store: StoreRoom,
target_store: StoreRoom,
product: Product,
quantity: int,
requester: AbstractBaseUser,
desc: Optional[str] = None
) -> StoreTransferRequest:
"""
سرویس ثبت درخواست انتقال جدید
"""
_check_source_store_permission(source_store, requester)
if source_store.id == target_store.id:
raise ValidationError(_("انبار مبدا و مقصد نمی‌توانند یکسان باشند."))
if quantity <= 0:
raise ValidationError(_("تعداد انتقال باید بیشتر از صفر باشد."))
# در زمان ثبت درخواست، نیازی به قفل دیتابیس نیست (فقط چک اولیه)
current_stock = get_product_current_stock(source_store, product, lock_for_update=False)
if current_stock < quantity:
raise ValidationError(_("موجودی انبار مبدا کافی نیست. موجودی فعلی: %(stock)s") % {'stock': current_stock})
transfer_request = StoreTransferRequest.objects.create(
source_store=source_store,
target_store=target_store,
product=product,
quantity=quantity,
requester=requester,
desc=desc,
status=StoreTransferRequest.Status.PENDING
)
logger.info(
f"Transfer request #{transfer_request.id} created by user #{requester.id}",
extra={"transfer_id": transfer_request.id, "quantity": quantity}
)
return transfer_request
@transaction.atomic
def approve_transfer_request(*, request_id: int, approver: AbstractBaseUser) -> StoreTransferRequest:
"""
سرویس تایید انتقال: قفل رکورد درخواست و رکوردهای انبار، کسر از مبدا، ورود به مقصد و تغییر وضعیت
"""
try:
# ۱. قفل کردن رکورد درخواست برای جلوگیری از Double Approval
transfer = StoreTransferRequest.objects.select_for_update().get(pk=request_id)
except StoreTransferRequest.DoesNotExist:
raise ValidationError(_("درخواست انتقال مورد نظر یافت نشد."))
if transfer.status != StoreTransferRequest.Status.PENDING:
raise ValidationError(_("این درخواست قبلاً تعیین تکلیف شده است و قابل تایید نیست."))
# ۲. بررسی سطح دسترسی تاییدکننده
_check_target_store_permission(transfer.target_store, approver)
# ۳. بررسی موجودی با فعال‌سازی قفل (lock_for_update=True) برای جلوگیری از Race Condition موجودی انبار
current_stock = get_product_current_stock(transfer.source_store, transfer.product, lock_for_update=True)
if current_stock < transfer.quantity:
raise ValidationError(
_("موجودی انبار مبدا برای تایید این انتقال کافی نیست. موجودی فعلی: %(stock)s") % {'stock': current_stock}
)
now = timezone.now()
# ۴. صدور سند خروج قطعی از انبار مبدا
out_tx = ProductInStore.objects.create(
StoreRoom=transfer.source_store,
Product=transfer.product,
s_in=0,
s_out=transfer.quantity,
movement_type=ProductInStore.MovementType.TRANSFER,
desc=f"انتقال به {transfer.target_store.store_name} | درخواست #{transfer.id} | تایید توسط {approver.username} | {transfer.desc}"
)
# ۵. صدور سند ورود قطعی به انبار مقصد
in_tx = ProductInStore.objects.create(
StoreRoom=transfer.target_store,
Product=transfer.product,
s_in=transfer.quantity,
s_out=0,
movement_type=ProductInStore.MovementType.TRANSFER,
desc=f"انتقال از {transfer.source_store.store_name} | درخواست #{transfer.id} | تایید توسط {approver.username} | {transfer.desc}"
)
# ۶. به‌روزرسانی نهایی درخواست
transfer.status = StoreTransferRequest.Status.APPROVED
transfer.approver = approver
transfer.action_date = now
transfer.out_transaction = out_tx
transfer.in_transaction = in_tx
transfer.save(update_fields=['status', 'approver', 'action_date', 'out_transaction', 'in_transaction', 'updated_at'])
logger.info(
f"Transfer request #{transfer.id} approved successfully by user #{approver.id}",
extra={"transfer_id": transfer.id, "out_tx": out_tx.id, "in_tx": in_tx.id}
)
return transfer
@transaction.atomic
def reject_transfer_request(*, request_id: int, rejector: AbstractBaseUser, reason: str = "") -> StoreTransferRequest:
"""
سرویس رد درخواست توسط انبار مقصد
"""
try:
transfer = StoreTransferRequest.objects.select_for_update().get(pk=request_id)
except StoreTransferRequest.DoesNotExist:
raise ValidationError(_("درخواست انتقال مورد نظر یافت نشد."))
if transfer.status != StoreTransferRequest.Status.PENDING:
raise ValidationError(_("این درخواست قبلاً تعیین تکلیف شده است."))
_check_target_store_permission(transfer.target_store, rejector)
transfer.status = StoreTransferRequest.Status.REJECTED
transfer.approver = rejector
transfer.action_date = timezone.now()
if reason:
transfer.rejection_reason = reason
transfer.save(update_fields=['status', 'approver', 'action_date', 'rejection_reason', 'updated_at'])
logger.info(f"Transfer request #{transfer.id} rejected by user #{rejector.id}. Reason: {reason}")
return transfer
@transaction.atomic
def cancel_transfer_request(*, request_id: int, user: AbstractBaseUser) -> StoreTransferRequest:
"""
سرویس انصراف از انتقال توسط ایجادکننده یا مسئول انبار مبدا
"""
try:
transfer = StoreTransferRequest.objects.select_for_update().get(pk=request_id)
except StoreTransferRequest.DoesNotExist:
raise ValidationError(_("درخواست انتقال مورد نظر یافت نشد."))
if transfer.status != StoreTransferRequest.Status.PENDING:
raise ValidationError(_("فقط درخواست‌های در انتظار تایید قابل انصراف هستند."))
if not (user.is_superuser or transfer.requester_id == user.id or transfer.source_store.user.filter(id=user.id).exists()):
raise PermissionDenied(_("شما اجازه انصراف از این درخواست را ندارید."))
transfer.status = StoreTransferRequest.Status.CANCELED
transfer.approver = user
transfer.action_date = timezone.now()
transfer.save(update_fields=['status', 'approver', 'action_date', 'updated_at'])
logger.info(f"Transfer request #{transfer.id} canceled by user #{user.id}")
return transfer