store transfer and panel anbar

This commit is contained in:
javad committed 2026-07-03 16:52:04 +03:30
1 parent bd775d6249
commit c5e9b1d18a
13 files changed
+2968 -190

No files matched your search

+32 -1
View File
@@ -18,4 +18,35 @@ admin.site.register(Article)
admin.site.register(Sarresid)
admin.site.register(Action)
admin.site.register(CacheTable)
admin.site.register(ProductStockHistory)
admin.site.register(ProductStockHistory)
@admin.register(StoreTransferRequest)
class StoreTransferRequestAdmin(admin.ModelAdmin):
# ۱. حل مشکل لودینگ و هنگ کردن فرم (تبدیل منوی کشویی غول‌آسا به باکس شناسه عددی)
# این دستور فیلدهای پرجمعیت را به شدت بهینه و سبک می‌کند
raw_id_fields = ['out_transaction', 'in_transaction', 'product', 'requester', 'approver']
# ۲. ستون‌هایی که می‌خواهید در صفحه جدول (Changelist) نمایش داده شوند
list_display = [
'id',
'source_store',
'target_store',
'product',
'quantity',
'status',
'created_at',
'action_date'
]
# ۳. فیکس قطعی باگ N+1 (لود کردن روابط در یک کوئری واحد به جای صدها کوئری تودرتو برای __str__)
list_select_related = ['source_store', 'target_store', 'product', 'requester', 'approver']
# ۴. فیلترهای سمت راست صفحه (فقط روی فیلدهای کم‌جمعیت مثل وضعیت و انبار فیلتر بگذارید)
list_filter = ['status', 'source_store', 'target_store', 'created_at']
# ۵. باکس جستجوی بهینه شده
search_fields = ['id', 'desc', 'product__product_name', 'requester__username']
# ۶. غیرقابل ادیت کردن فیلدهای حساس مالی در پنل ادمین (اختیاری اما برای Enterprise الزامی است)
readonly_fields = ['created_at', 'updated_at']
@@ -0,0 +1,17 @@
# Generated by Django 3.2.25 on 2026-07-03 10:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0047_alter_productinstore_movement_type'),
]
operations = [
migrations.AlterModelOptions(
name='productinstore',
options={'permissions': [('ViewProductInStore', 'ViewProductInStore'), ('EditProductInStore', 'EditProductInStore')]},
),
]
@@ -0,0 +1,17 @@
# Generated by Django 3.2.25 on 2026-07-03 10:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0048_alter_productinstore_options'),
]
operations = [
migrations.AlterModelOptions(
name='productinstore',
options={'permissions': [('ViewProductInStore', 'ViewProductInStore'), ('EditProductInStore', 'EditProductInStore'), ('OnlyAnbar', 'OnlyAnbar')]},
),
]
@@ -0,0 +1,41 @@
# Generated by Django 3.2.25 on 2026-07-03 12:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0049_alter_productinstore_options'),
]
operations = [
migrations.CreateModel(
name='StoreTransferRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(verbose_name='تعداد انتقال')),
('status', models.CharField(choices=[('PENDING', 'در انتظار تایید انبار مقصد'), ('APPROVED', 'تایید شده و منتقل شده'), ('REJECTED', 'رد شده توسط انبار مقصد'), ('CANCELED', 'انصراف توسط مبدا')], db_index=True, default='PENDING', max_length=15, verbose_name='وضعیت')),
('desc', models.CharField(blank=True, max_length=500, null=True, verbose_name='توضیحات درخواست')),
('rejection_reason', models.CharField(blank=True, max_length=500, null=True, verbose_name='دلیل رد درخواست')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='زمان ثبت درخواست')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='زمان آخرین به\u200cروزرسانی')),
('action_date', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='زمان تعیین تکلیف')),
('approver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='processed_transfers', to=settings.AUTH_USER_MODEL, verbose_name='کاربر تعیین\u200cتکلیف\u200cکننده')),
('in_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_in_ref', to='admin_panel.productinstore', verbose_name='سند ورود انبار مقصد')),
('out_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_out_ref', to='admin_panel.productinstore', verbose_name='سند خروج انبار مبدا')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='admin_panel.product', verbose_name='کالا')),
('requester', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='requested_transfers', to=settings.AUTH_USER_MODEL, verbose_name='کاربر درخواست\u200cدهنده')),
('source_store', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_transfers', to='admin_panel.storeroom', verbose_name='انبار مبدا')),
('target_store', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_transfers', to='admin_panel.storeroom', verbose_name='انبار مقصد')),
],
options={
'verbose_name': 'درخواست انتقال بین انبار',
'verbose_name_plural': 'درخواست\u200cهای انتقال بین انبار',
'ordering': ['-created_at'],
},
),
]
+93 -1
View File
@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
# from django.db.models.signals import post_save, post_delete
@@ -146,7 +147,12 @@ class Factor(models.Model):
self.save()
class ProductInStore(models.Model):
class Meta:
permissions = [
("ViewProductInStore", "ViewProductInStore"),
("EditProductInStore", "EditProductInStore"),
("OnlyAnbar", "OnlyAnbar"),
]
class MovementType(models.TextChoices):
SALE = 'SALE', 'فروش عادی'
WARRANTY = 'WARRANTY', 'گارانتی'
@@ -273,6 +279,92 @@ class ProductStockHistory(models.Model):
def __str__(self):
return f"{self.product.product_name} in {self.store_room.store_name} - Stock: {self.current_stock}"
class StoreTransferRequest(models.Model):
class Status(models.TextChoices):
PENDING = 'PENDING', 'در انتظار تایید انبار مقصد'
APPROVED = 'APPROVED', 'تایید شده و منتقل شده'
REJECTED = 'REJECTED', 'رد شده توسط انبار مقصد'
CANCELED = 'CANCELED', 'انصراف توسط مبدا'
source_store = models.ForeignKey(
'StoreRoom',
on_delete=models.PROTECT,
related_name='outgoing_transfers',
verbose_name="انبار مبدا"
)
target_store = models.ForeignKey(
'StoreRoom',
on_delete=models.PROTECT,
related_name='incoming_transfers',
verbose_name="انبار مقصد"
)
product = models.ForeignKey(
'Product',
on_delete=models.PROTECT,
verbose_name="کالا"
)
quantity = models.PositiveIntegerField(verbose_name="تعداد انتقال")
status = models.CharField(
max_length=15,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
verbose_name="وضعیت"
)
desc = models.CharField(max_length=500, blank=True, null=True, verbose_name="توضیحات درخواست")
rejection_reason = models.CharField(max_length=500, blank=True, null=True, verbose_name="دلیل رد درخواست")
requester = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name='requested_transfers',
verbose_name="کاربر درخواست‌دهنده"
)
approver = models.ForeignKey(
User,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='processed_transfers',
verbose_name="کاربر تعیین‌تکلیف‌کننده"
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="زمان ثبت درخواست")
updated_at = models.DateTimeField(auto_now=True, verbose_name="زمان آخرین به‌روزرسانی")
action_date = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="زمان تعیین تکلیف")
out_transaction = models.ForeignKey(
'ProductInStore',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_out_ref',
verbose_name="سند خروج انبار مبدا"
)
in_transaction = models.ForeignKey(
'ProductInStore',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_in_ref',
verbose_name="سند ورود انبار مقصد"
)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"[{self.get_status_display()}] {self.quantity} عدد {self.product.product_name} از {self.source_store.store_name} به {self.target_store.store_name}"
def clean(self):
super().clean()
if self.source_store_id == self.target_store_id:
raise ValidationError("انبار مبدا و مقصد نمی‌توانند یکسان باشند.")
if self.quantity and self.quantity <= 0:
raise ValidationError("تعداد انتقال باید بیشتر از صفر باشد.")
class FactorIteam(models.Model): # پیشنهاد: در آینده املای نام این کلاس را به FactorItem اصلاح کنید
factor = models.ForeignKey(Factor, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True)
+207
View File
@@ -0,0 +1,207 @@
# 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}"
)
# ۵. صدور سند ورود قطعی به انبار مقصد
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.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
+1 -2
View File
@@ -47,7 +47,7 @@
</a>
{% endif %}
{% if not perms.admin_panel.ViewStore %}
{% if not perms.admin_panel.ViewStore %}
<a href="{% url 'admin_panel:ActionListUserToday' request.user.id %}" class="navbar-item">
اصلی
</a>
@@ -63,7 +63,6 @@
</a>
{% endif %}
<a href="{% url 'admin_panel:stores' %}" class="navbar-item">
انبار
</a>
@@ -96,16 +96,28 @@
<div id="storeData" class="column is-vcentered is-centered pt-0 is-12-mobile is-12-desktop">
<div class="tabs is-centered m-0">
<ul>
{% with url_name=request.resolver_match.url_name %}
<li class="{% if url_name == 'store' %}is-active{% endif %}"><a href="{% url 'admin_panel:store' s_id %}">موجودی</a></li>
<li class="{% if url_name == 'operations' %}is-active{% endif %}"><a href="{% url 'admin_panel:operations' s_id %}">دفترعملیات</a></li>
{% endwith %}
</ul>
</div>
<div class="tabs is-centered m-0">
<ul>
{% with url_name=request.resolver_match.url_name %}
<li class="{% if url_name == 'store' %}is-active{% endif %}">
<a href="{% url 'admin_panel:store' s_id %}">موجودی</a>
</li>
<li class="{% if url_name == 'operations' %}is-active{% endif %}">
<a href="{% url 'admin_panel:operations' s_id %}">دفترعملیات</a>
</li>
<li class="{% if url_name == 'store_transfers' %}is-active{% endif %}">
<a href="{% url 'admin_panel:store_transfers' s_id %}">
انتقال‌های انبار
{% if storeO.incoming_transfers.all|length > 0 %}
<span class="tag is-danger is-rounded is-small ml-1">!</span>
{% endif %}
</a>
</li>
{% endwith %}
</ul>
</div>
{% block storeBody %}
+88 -80
View File
@@ -3,86 +3,103 @@
{% load static %}
{% block storeBody %}
<p class="has-text-centered mt-2 mb-2 is-size-5 ">{{ storeO.store_name }}</p>
<p class="has-text-centered mt-2 mb-2 is-size-5">{{ storeO.store_name }}</p>
<form class="columns m-0 is-multiline is-mobile" method="post">
{% csrf_token %}
<div class="field column is-6-desktop is-6-mobile">
<div class="control ">
<input id="searchQS" value="{{ djsearchQS }}" name="searchQS" class="input is-fullwidth" type="text" placeholder="نام محصول">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<div class="control ">
<div class="select is-fullwidth">
<select id="mojodi" name="mojodi">
<option value="">وضعیت موجودی</option>
<option value="0">عدم موجودی</option>
<option value="1">نقطه سفارش</option>
</select>
<div class="field column is-6-desktop is-6-mobile">
<div class="control">
<input id="searchQS" value="{{ djsearchQS }}" name="searchQS" class="input is-fullwidth" type="text" placeholder="نام محصول">
</div>
</div>
</div>
<div class="field column is-3-desktop is-12-mobile">
<div class="control ">
<input type="submit" class="button is-link is-fullwidth" value="جستجو"/>
<div class="field column is-3-desktop is-6-mobile">
<div class="control">
<div class="select is-fullwidth">
<select id="mojodi" name="mojodi">
<option value="">وضعیت موجودی</option>
<option value="0">عدم موجودی</option>
<option value="1">نقطه سفارش</option>
</select>
</div>
</div>
</div>
</div>
</form>
<div class="field column is-3-desktop is-12-mobile">
<div class="control">
<input type="submit" class="button is-link is-fullwidth" value="جستجو"/>
</div>
</div>
</form>
<a class="button is-info mb-3 {% if searchStatus %} {% else %} is-hidden {% endif %}" href="{% url 'admin_panel:store' s_id %}">لغو جستجو و مشاهده همه</a>
<a class="button is-info mb-3 {% if searchStatus %}{% else %}is-hidden{% endif %}" href="{% url 'admin_panel:store' s_id %}">لغو جستجو و مشاهده همه</a>
<div class="table-container">
<table class="table m-auto has-text-centered table is-bordered is-striped is-narrow is-fullwidth mojodiTB ">
<table class="table m-auto has-text-centered table is-bordered is-striped is-narrow is-fullwidth mojodiTB">
<thead>
<tr>
<th style="min-width: 200px;">نام محصول</th>
<th>قیمت</th>
<th>موجودی</th>
<th style="min-width: 100px;">عملیات</th>
<th>موجودی فیزیکی</th>
<th>در انتظار خروج (رزرو)</th>
<th>در انتظار ورود (در راه)</th>
<th style="min-width: 100px;">عملیات</th>
</tr>
</thead>
<tfoot>
<tr>
<th>نام محصول</th>
<th>قیمت</th>
<th>موجودی</th>
<th>موجودی فیزیکی</th>
<th>در انتظار خروج (رزرو)</th>
<th>در انتظار ورود (در راه)</th>
<th>عملیات</th>
</tr>
</tfoot>
<tbody>
{% for prd in productSum %}
<tr prd_id="{{ prd.id }}" class="openPrdEdit" p_cat="{{ prd.ProductCategory.id }}">
<td >
<a {% if perms.admin_panel.ViewStore %}href="{% url 'admin_panel:operationProduct' s_id prd.id %}"{% endif %}>
<span class="product_name">{{ prd.product_name }}</span>
</a>
<td>
<a {% if perms.admin_panel.OnlyAnbar %}href="{% url 'admin_panel:operationProduct' s_id prd.id %}"{% endif %}>
<span class="product_name font-weight-bold">{{ prd.product_name }}</span>
</a>
</td>
<td class="product_price">{{ prd.product_price }} </td>
<td class="product_mojodi" >{{ prd.mojodi }}</td>
<td>
<td class="product_price">{{ prd.product_price }}</td>
<td class="product_mojodi has-text-weight-bold is-size-6">
{{ prd.mojodi }}
</td>
{% if perms.admin_panel.ViewStore %}
<span class="Oedit_prd"><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/edit.png' %}"></span>
<span class="delet_prd"><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/delet.png' %}"></span>
<td>
{% if prd.pending_out > 0 %}
<span class="tag is-warning is-medium" title="ارسال شده به انبار دیگر و منتظر تایید">
{{ prd.pending_out }} -
</span>
{% else %}
<span class="has-text-grey-light">0</span>
{% endif %}
</td>
<td>
{% if prd.pending_in > 0 %}
<span class="tag is-info is-medium" title="ارسال شده به این انبار و منتظر تایید شما">
{{ prd.pending_in }} +
</span>
{% else %}
<span class="has-text-grey-light">0</span>
{% endif %}
</td>
<td>
{% if perms.admin_panel.ViewStore or perms.admin_panel.OnlyAnbar %}
<span class="Oedit_prd" style="cursor: pointer;"><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/edit.png' %}"></span>
<span class="delet_prd" style="cursor: pointer;"><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/delet.png' %}"></span>
<a href="{% url 'admin_panel:newOperation' s_id prd.id %}">
<span><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/add.png' %}"></span>
</a>
{% endif %}
</td>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -97,10 +114,8 @@
<button class="delete close_edit" aria-label="close"></button>
</header>
<form action="{% url 'admin_panel:productEdit' %}" id="formcsEdit" class="has-background-white brad5" method="post">
<section class="modal-card-body">
<section class="modal-card-body">
{% csrf_token %}
<input id="i_pcode" type="hidden" name="prd_code" value="">
<div id="userDT" class="is-hidden-mobile">
@@ -111,11 +126,9 @@
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">دسته کالا</label>
<div class="control">
<div class="select is-fullwidth">
<select id="Editcategory" name="category" required>
<option value=""></option>
@@ -124,7 +137,6 @@
{% endfor %}
</select>
</div>
</div>
</div>
@@ -134,51 +146,47 @@
<input id="Editproduct_price" required value="" name="price" class="input an_name" type="text" placeholder="قیمت">
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<input value="ذخیره" type="submit" class="button is-success">
<button class="button close_edit">لغو</button>
</footer>
</section>
<footer class="modal-card-foot">
<input value="ذخیره" type="submit" class="button is-success">
<button type="button" class="button close_edit">لغو</button>
</footer>
</form>
</div>
</div>
<script>
{% if not mojodi %}
var mojodiVar = '';
{% else %}
var mojodiVar = {{ mojodi }};
{% endif %}
var objpname = null;
{% if not mojodi %}
var mojodiVar = '';
{% else %}
var mojodiVar = {{ mojodi }};
{% endif %}
var objpname = null;
$('#mojodi').val(mojodiVar);
$('#mojodi').val( mojodiVar );
$(".delet_prd").click(function(){
var prd_id = $(this).parent().parent().attr('prd_id');
if ( confirm("از حذف مطمئن هستید؟") ){
window.location.href = '/panel/product/del/'+prd_id+"/"+"{{ s_id }}";
if (confirm("از حذف مطمئن هستید؟")) {
window.location.href = '/panel/product/del/' + prd_id + "/" + "{{ s_id }}";
}
});
$(".close_edit").click(function(){
$("#edit_prd").removeClass('is-active');
});
$(".Oedit_prd").click(function(){
$("#edit_prd").addClass('is-active');
prd_id = $(this).parent().parent().attr("prd_id");
p_cat = $(this).parent().parent().attr("p_cat");
objpname = $(this).parent().parent().find('.product_name')
//alert($(this).parent().parent().find('.product_name'))
var prd_id = $(this).parent().parent().attr("prd_id");
var p_cat = $(this).parent().parent().attr("p_cat");
objpname = $(this).parent().parent().find('.product_name');
$("#i_pcode").val(prd_id);
$("#EditnamePrd").val( $(this).parent().parent().find('.product_name').html() );
$("#Editproduct_price").val( $(this).parent().parent().find('.product_price').html() );
$("#Editcategory").val( p_cat );
$("#EditnamePrd").val($(this).parent().parent().find('.product_name').text().trim());
$("#Editproduct_price").val($(this).parent().parent().find('.product_price').text().trim());
$("#Editcategory").val(p_cat);
});
</script>
{% endblock %}
@@ -0,0 +1,224 @@
{% extends "./baseStore.html" %}
{% load custom_tags %}
{% load static %}
{% block storeBody %}
<div class="container is-fluid pt-3">
{% if messages %}
<div class="mb-4">
{% for message in messages %}
<div class="notification {% if message.tags == 'success' %}is-success{% elif message.tags == 'error' %}is-danger{% elif message.tags == 'warning' %}is-warning{% else %}is-info{% endif %} is-light">
<button class="delete"></button>
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<div class="box mb-5 border-top-primary">
<h3 class="title is-5 has-text-primary mb-3">
📥 بارهای ورودی در انتظار تایید شما
<span class="tag is-primary is-rounded ml-2">{{ incoming_pending.count }}</span>
</h3>
{% if incoming_pending %}
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-fullwidth has-text-centered">
<thead class="has-background-light">
<tr>
<th>کد درخواست</th>
<th>کالا</th>
<th>تعداد</th>
<th>ارسال کننده (انبار مبدا)</th>
<th>کاربر ثبت‌کننده</th>
<th>زمان ارسال</th>
<th style="min-width: 180px;">اقدام انباردار</th>
</tr>
</thead>
<tbody>
{% for item in incoming_pending %}
<tr>
<td class="has-text-weight-bold">#{{ item.id }}</td>
<td class="has-text-weight-bold has-text-link">{{ item.product.product_name }}</td>
<td class="is-size-5 has-text-weight-bold has-text-success">{{ item.quantity }}</td>
<td>{{ item.source_store.store_name }}</td>
<td>{{ item.requester.first_name }} {{ item.requester.last_name }}</td>
<td dir="ltr">{{ item.created_at|toJalali }}</td>
<td>
<div class="buttons is-centered m-0">
<form action="{% url 'admin_panel:process_transfer_action' item.id %}" method="post" class="m-0" onsubmit="return confirm('آیا از تایید و ورود این کالا به انبار خود اطمینان دارید؟');">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<button type="submit" class="button is-success is-small ml-1">✓ تایید و دریافت</button>
</form>
<button type="button" class="button is-danger is-small btn-reject" data-id="{{ item.id }}" data-name="{{ item.product.product_name }}" data-qty="{{ item.quantity }}">
✕ رد بار
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="has-text-grey has-text-centered py-4">هیچ بار ورودی جدیدی در انتظار تایید شما نیست.</p>
{% endif %}
</div>
<div class="box mb-5">
<h3 class="title is-5 has-text-info mb-3">
📤 بارهای ارسالی شما (در انتظار تایید انبار مقصد)
<span class="tag is-info is-rounded ml-2">{{ outgoing_pending.count }}</span>
</h3>
{% if outgoing_pending %}
<div class="table-container">
<table class="table is-bordered is-striped is-narrow is-fullwidth has-text-centered">
<thead class="has-background-light">
<tr>
<th>کد درخواست</th>
<th>کالا</th>
<th>تعداد</th>
<th>انبار مقصد</th>
<th>زمان ارسال</th>
<th>توضیحات شما</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for item in outgoing_pending %}
<tr>
<td>#{{ item.id }}</td>
<td class="has-text-weight-bold">{{ item.product.product_name }}</td>
<td class="has-text-weight-bold">{{ item.quantity }}</td>
<td class="has-text-info has-text-weight-bold">{{ item.target_store.store_name }}</td>
<td dir="ltr">{{ item.created_at|toJalali }}</td>
<td>{{ item.desc|default:"-" }}</td>
<td><span class="tag is-warning">منتظر تحویل</span></td>
<td>
<form action="{% url 'admin_panel:process_transfer_action' item.id %}" method="post" class="m-0" onsubmit="return confirm('آیا قصد انصراف و بازگرداندن بار از مسیر انتقال را دارید؟');">
{% csrf_token %}
<input type="hidden" name="action" value="cancel">
<button type="submit" class="button is-warning is-small">↩ انصراف از ارسال</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="has-text-grey has-text-centered py-4">هیچ بار ارسالی معلقی ندارید.</p>
{% endif %}
</div>
<div class="box">
<h3 class="title is-6 has-text-grey-dark mb-3">🕒 تاریخچه ۵۰ انتقال اخیر انبار</h3>
<div class="table-container">
<table class="table is-bordered is-narrow is-fullwidth has-text-centered is-size-7">
<thead>
<tr>
<th>کد</th>
<th>کالا</th>
<th>تعداد</th>
<th>مبدا</th>
<th>مقصد</th>
<th>وضعیت</th>
<th>تعیین‌تکلیف کننده</th>
<th>زمان اقدام</th>
<th>توضیحات / دلیل رد</th>
</tr>
</thead>
<tbody>
{% for item in history_list %}
<tr>
<td>#{{ item.id }}</td>
<td class="has-text-weight-bold">{{ item.product.product_name }}</td>
<td>{{ item.quantity }}</td>
<td>{% if item.source_store_id == storeO.id %}<span class="tag is-light">انبار شما</span>{% else %}{{ item.source_store.store_name }}{% endif %}</td>
<td>{% if item.target_store_id == storeO.id %}<span class="tag is-light">انبار شما</span>{% else %}{{ item.target_store.store_name }}{% endif %}</td>
<td>
{% if item.status == 'APPROVED' %}
<span class="tag is-success is-light">✓ تایید و تحویل شد</span>
{% elif item.status == 'REJECTED' %}
<span class="tag is-danger is-light">✕ رد شد</span>
{% elif item.status == 'CANCELED' %}
<span class="tag is-warning is-light">انصراف مبدا</span>
{% endif %}
</td>
<td>{{ item.approver.first_name }} {{ item.approver.last_name }}</td>
<td dir="ltr">{{ item.action_date|toJalali|default:"-" }}</td>
<td class="has-text-right">{{ item.rejection_reason|default:item.desc|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="9" class="has-text-grey">هیچ تاریخچه‌ای یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div id="modal-reject" class="modal">
<div class="modal-background close-modal"></div>
<div class="modal-card">
<header class="modal-card-head has-background-danger-light">
<p class="modal-card-title has-text-danger-dark is-size-5">⚠️ اعلام دلیل رد دریافت بار</p>
<button class="delete close-modal" aria-label="close"></button>
</header>
<form id="form-reject" action="" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="reject">
<section class="modal-card-body">
<p class="mb-3">شما در حال رد کردن ورود <strong id="modal-qty"></strong> عدد <strong id="modal-prd-name"></strong> به انبار خود هستید.</p>
<div class="field">
<label class="label">علت رد بار (الزام جهت اطلاع انبار مبدا): <span class="has-text-danger">*</span></label>
<div class="control">
<textarea name="reason" class="textarea" required placeholder="مثال: مغایرت در تعداد ارسالی / خرابی فیزیکی کالا / ارسال اشتباهی به این انبار..." rows="3"></textarea>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button type="submit" class="button is-danger">تایید و رد بار</button>
<button type="button" class="button close-modal">انصراف</button>
</footer>
</form>
</div>
</div>
<script>
$(document).ready(function() {
// بستن پیام‌های هشدار
$('.notification .delete').click(function() {
$(this).parent().fadeOut();
});
// باز کردن مودال رد بار و تنظیم اطلاعات
$('.btn-reject').click(function() {
var id = $(this).data('id');
var name = $(this).data('name');
var qty = $(this).data('qty');
$('#modal-prd-name').text(name);
$('#modal-qty').text(qty);
// تنظیم اکشن فرم به آدرس مخصوص همین تراکنش
$('#form-reject').attr('action', '/panel/transfer/' + id + '/action/');
$('#modal-reject').addClass('is-active');
});
// بستن مودال
$('.close-modal').click(function() {
$('#modal-reject').removeClass('is-active');
$('#form-reject').find('textarea').val('');
});
});
</script>
{% endblock %}
+3
View File
@@ -53,6 +53,9 @@ urlpatterns = [
path('store/<int:s_id>/operations/<int:p_id>', views.operationProduct, name='operationProduct'),
path('store/<int:s_id>/operation/<int:p_id>/new', views.newOperation, name='newOperation'),
path('store/new', views.newStore, name='newStore'),
# === مسیرهای مدیریت انتقال بین انبارها ===
path('store/<int:s_id>/transfers/', views.store_transfers, name='store_transfers'),
path('transfer/<int:transfer_id>/action/', views.process_transfer_action, name='process_transfer_action'),
path('product/add', views.productAdd, name='productAdd'),
path('product/edit', views.productEdit, name='productEdit'),
path('operation', views.operation, name='operation'),
File diff suppressed because it is too large. Load diff
+253 -96
View File
@@ -4,9 +4,11 @@ from django.urls import reverse # تکراری در کد اصلی شما بود
from persiantools.jdatetime import JalaliDate
from persiantools import characters, digits
import datetime
from django.contrib import messages
from django.views.decorators.http import require_POST
from django.utils import timezone # جدید
from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
from django.core.exceptions import ValidationError # جدید
from django.core.exceptions import PermissionDenied, ValidationError # جدید
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest # بخشBadRequest جدید
@@ -18,8 +20,9 @@ from django.contrib.auth.decorators import login_required, permission_required
import os
import json # جدید
import logging # جدید
from .models import *
from .services import create_transfer_request, approve_transfer_request, reject_transfer_request, cancel_transfer_request
logger = logging.getLogger(__name__)
@@ -883,6 +886,7 @@ def entezar(request):
return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn})
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def stores(request):
if request.user.username not in ('admin', 'office1', 'office2', 'office3', 'khatibi'):
all_store = StoreRoom.objects.filter(user__id=request.user.id).order_by('id')[:20]
@@ -891,29 +895,87 @@ def stores(request):
data = {'all_store': all_store}
return render(request, 'admin_panel/stores.html', data)
from django.db.models import Sum, Subquery, OuterRef, IntegerField, Value
from django.db.models.functions import Coalesce
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required, permission_required
from .models import StoreRoom, ProductCategory, Product, ProductInStore, StoreTransferRequest
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def store(request, s_id):
productCategory = ProductCategory.objects.all()
storeO = StoreRoom.objects.get(id=s_id)
storeO = get_object_or_404(StoreRoom, id=s_id)
searchStatus = False
searchQS = request.POST.get('searchQS', '')
searchQS = request.POST.get('searchQS', '').strip()
mojodi = request.POST.get('mojodi', '')
#return HttpResponse(mojodi)
if searchQS !='':
productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400,product_name__contains=searchQS).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') ))
if mojodi == "0":
productSum = productSum.filter(mojodi__lte=0)
# ۱. محاسبه موجودی فیزیکی کل کالاها با یک کوئری تک‌ضرب و سریع GROUP BY
stock_balances = ProductInStore.objects.filter(
StoreRoom=storeO, year=1400
).values('Product_id').annotate(
total=Sum('s_in') - Sum('s_out')
).order_by()
stock_dict = {item['Product_id']: item['total'] for item in stock_balances}
# ۲. محاسبه کل کالاهای در انتظار خروج
pending_out_balances = StoreTransferRequest.objects.filter(
source_store=storeO, status=StoreTransferRequest.Status.PENDING
).values('product_id').annotate(
total=Sum('quantity')
).order_by()
pending_out_dict = {item['product_id']: item['total'] for item in pending_out_balances}
# ۳. محاسبه کل کالاهای در انتظار ورود
pending_in_balances = StoreTransferRequest.objects.filter(
target_store=storeO, status=StoreTransferRequest.Status.PENDING
).values('product_id').annotate(
total=Sum('quantity')
).order_by()
pending_in_dict = {item['product_id']: item['total'] for item in pending_in_balances}
# ۴. استخراج سریع تمام آیدی‌های منحصربه‌فرد مرتبط با این انبار در سطح حافظه
all_pids = set(stock_dict.keys()) | set(pending_out_dict.keys()) | set(pending_in_dict.keys())
# ۵. کوئری نهایی فوق‌بهینه برای خواندن کالاها (حل باگ N+1 با دستور select_related)
products_qs = Product.objects.select_related('ProductCategory').filter(id__in=all_pids)
# اعمال فیلتر متنی جستجو در سطح دیتابیس
if searchQS:
products_qs = products_qs.filter(product_name__icontains=searchQS)
searchStatus = True
else:
productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') ))
if mojodi == "0":
searchStatus = True
productSum = productSum.filter(mojodi__lte=0)
data = {'storeO': storeO,'productSum': productSum, 's_id': s_id, 'searchStatus': searchStatus, "mojodi": mojodi, 'djsearchQS':searchQS, 'productCategory': productCategory}
# 🛠️ اصلاح فرآیند ترتیب: استفاده از 'id' جهت انطباق ۱۰۰٪ با ترتیب پیش‌فرض و سنتی دیتابیس شما
# نکته: اگر مایلید بر اساس زمان ایجاد مرتب شود، می‌توانید آن را به 'createdDT' یا '-createdDT' تغییر دهید.
products_list = list(products_qs.order_by('id'))
# ۶. معماری زمان ثابت O(1): چسباندن پرسرعت دیتاها در حافظه رم سرور
for prd in products_list:
prd.mojodi = stock_dict.get(prd.id, 0)
prd.pending_out = pending_out_dict.get(prd.id, 0)
prd.pending_in = pending_in_dict.get(prd.id, 0)
# ۷. اعمال فیلترهای منطقی موجودی در لایه پایتون
if mojodi == "0":
products_list = [p for p in products_list if p.mojodi <= 0]
searchStatus = True
elif mojodi == "1":
products_list = [p for p in products_list if p.mojodi > 0]
searchStatus = True
data = {
'storeO': storeO,
'productSum': products_list,
's_id': s_id,
'searchStatus': searchStatus,
"mojodi": mojodi,
'djsearchQS': searchQS,
'productCategory': productCategory
}
return render(request, 'admin_panel/store.html', data)
@login_required(login_url='/panel/p_login/')
#@permission_required('admin_panel.ViewStore', raise_exception=True)
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def operations(request, s_id):
page = request.GET.get('page', 1)
productCategory = ProductCategory.objects.all()
@@ -942,7 +1004,7 @@ def operations(request, s_id):
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def operationProduct(request, s_id, p_id):
productCategory = ProductCategory.objects.all()
storeO = get_object_or_404(StoreRoom, id=s_id)
@@ -961,7 +1023,7 @@ def operationProduct(request, s_id, p_id):
return render(request, 'admin_panel/operations.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def newOperation(request, s_id, p_id):
productCategory = ProductCategory.objects.all()
storeO = StoreRoom.objects.get(id=s_id)
@@ -970,6 +1032,92 @@ def newOperation(request, s_id, p_id):
data = {'s_id': s_id,'p_id': p_id, 'allStores': allStores, 'storeO': storeO, 'product':product, 'productCategory': productCategory}
return render(request, 'admin_panel/newOperation.html', data )
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def store_transfers(request, s_id):
"""
داشبورد و کارتابل جامع مدیریت انتقال‌های انبار
"""
storeO = get_object_or_404(StoreRoom, id=s_id)
# ۱. کارتابل ورودی (بارهایی که به سمت این انبار آمده و منتظر تایید ما هستند)
# استفاده از select_related برای جلوگیری از صدها کوئری اضافی به دیتابیس
incoming_pending = StoreTransferRequest.objects.filter(
target_store=storeO,
status=StoreTransferRequest.Status.PENDING
).select_related('product', 'source_store', 'requester').order_by('-created_at')
# ۲. کارتابل خروجی (بارهایی که ما فرستادیم و منتظر تایید انبار مقصد هستیم)
outgoing_pending = StoreTransferRequest.objects.filter(
source_store=storeO,
status=StoreTransferRequest.Status.PENDING
).select_related('product', 'target_store', 'requester').order_by('-created_at')
# ۳. تاریخچه انتقالات اخیر (۵۰ مورد آخر تعیین تکلیف شده برای این انبار)
history_list = StoreTransferRequest.objects.filter(
Q(source_store=storeO) | Q(target_store=storeO)
).exclude(
status=StoreTransferRequest.Status.PENDING
).select_related('product', 'source_store', 'target_store', 'approver').order_by('-action_date')[:50]
data = {
'storeO': storeO,
's_id': s_id,
'incoming_pending': incoming_pending,
'outgoing_pending': outgoing_pending,
'history_list': history_list,
}
return render(request, 'admin_panel/store_transfers.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
@require_POST
def process_transfer_action(request, transfer_id):
"""
پردازش امن اقدامات انباردار (تایید، رد، انصراف) با تراکنش‌های اتمیک
"""
action = request.POST.get('action')
reason = request.POST.get('reason', '').strip()
# دریافت درخواست انتقال
transfer = get_object_or_404(StoreTransferRequest, id=transfer_id)
# تعیین انباری که کاربر از آنجا اقدام کرده جهت ریدایرکت صحیح
redirect_store_id = transfer.target_store_id if action in ['approve', 'reject'] else transfer.source_store_id
try:
if action == 'approve':
approve_transfer_request(request_id=transfer.id, approver=request.user)
messages.success(request, f"انتقال {transfer.quantity} عدد {transfer.product.product_name} با موفقیت تایید و به موجودی انبار اضافه شد.")
elif action == 'reject':
if not reason:
messages.error(request, "برای رد کردن بار، وارد کردن علت رد الزامی است.")
return redirect('admin_panel:store_transfers', s_id=redirect_store_id)
reject_transfer_request(request_id=transfer.id, rejector=request.user, reason=reason)
messages.warning(request, f"درخواست انتقال بار رد شد و به انبار مبدا اطلاع داده شد.")
elif action == 'cancel':
cancel_transfer_request(request_id=transfer.id, user=request.user)
messages.info(request, "درخواست انتقال توسط شما لغو گردید.")
else:
messages.error(request, "دستور ارسالی نامعتبر است.")
except ValidationError as e:
# نمایش خطاهای منطقی (مثلاً کمبود موجودی انبار مبدا در لحظه تایید)
error_text = ' '.join(e.messages) if hasattr(e, 'messages') else str(e)
messages.error(request, f"خطا در انجام عملیات: {error_text}")
except PermissionError as e:
messages.error(request, str(e))
except Exception as e:
logger.error(f"Error processing transfer action #{transfer_id}: {str(e)}", exc_info=True)
messages.error(request, "خطای سیستمی در پردازش تراکنش رخ داد.")
return redirect('admin_panel:store_transfers', s_id=redirect_store_id)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
def newStore(request):
@@ -981,7 +1129,7 @@ def newStore(request):
return redirect('/panel/store/')
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def productAdd(request):
namePrd = request.POST.get('namePrd', '')
category = request.POST.get('category', '')
@@ -1031,109 +1179,118 @@ def productDel(request, p_id, s_id):
return redirect('/panel/store/'+str(s_id))
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True)
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def operation(request):
if request.method != 'POST':
return HttpResponseBadRequest("متد ارسالی نامعتبر است.")
# استفاده از توابع امن اختصاصی پروژه شما برای جداسازی مقادیر عددی
# استفاده از توابع امن اختصاصی پروژه برای جداسازی مقادیر عددی
prd_id = _safe_int(request.POST.get('prd_id'))
actionType = request.POST.get('actionType', '')
action_type = request.POST.get('actionType', '')
tedad = _safe_int(request.POST.get('tedad'))
azanbar = _safe_int(request.POST.get('azanbar'))
beanbar = _safe_int(request.POST.get('beanbar'))
desc = request.POST.get('desc', '')
desc = request.POST.get('desc', '').strip()
if tedad <= 0:
return HttpResponseBadRequest("تعداد وارد شده باید بیشتر از صفر باشد.")
sRoom = get_object_or_404(StoreRoom, id=azanbar)
productO = get_object_or_404(Product, id=prd_id)
s_room = get_object_or_404(StoreRoom, id=azanbar)
product_obj = get_object_or_404(Product, id=prd_id)
try:
with transaction.atomic():
# ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا
qs = ProductInStore.objects.filter(
Product=productO, StoreRoom=sRoom, year=1400
)
# بررسی هوشمند پشتیبانی دیتابیس از قابلیت قفل ردیف (سازگار با SQLite و PostgreSQL)
if connection.features.has_select_for_update:
qs = qs.select_for_update()
# بارگذاری امن تراکنش‌ها در حافظه (RAM) جهت جلوگیری از خطای توابع تجمیعی همزمان با قفل دیتابیس
locked_records = list(qs)
# --- عملیات ورود دستی ---
if actionType == 'vorod':
pInStore = ProductInStore(
StoreRoom=sRoom,
Product=productO,
year=1400
)
pInStore.s_in = tedad
pInStore.desc = "ورود: "+str(tedad)+" به انبار " + str(sRoom.store_name) + "#" + str(desc)
pInStore.movement_type = ProductInStore.MovementType.DIRECT_IN
pInStore.save()
# --- عملیات خروج دستی یا انتقال بین انبارها ---
elif actionType in ['khoroj', 'transfer']:
# =====================================================================
# بخش اول: انتقال بین انبارها (واگذاری کامل به لایه سرویس)
# =====================================================================
if action_type == 'transfer':
if not beanbar:
return HttpResponse("انبار مقصد مشخص نشده است.")
# محاسبات دقیق و سریع موجودی کالا در سطح پایتون بدون درگیر کردن دیسک
total_in = sum(item.s_in for item in locked_records if item.s_in)
total_out = sum(item.s_out for item in locked_records if item.s_out)
mojodi_prd = total_in - total_out
target_store = get_object_or_404(StoreRoom, id=_safe_int(beanbar))
# فراخوانی لایه سرویس برای ثبت درخواست انتقال (بدون تغییر مستقیم موجودی)
create_transfer_request(
source_store=s_room,
target_store=target_store,
product=product_obj,
quantity=tedad,
requester=request.user,
desc=desc or f"درخواست انتقال از انبار {s_room.store_name} به {target_store.store_name}"
)
logger.info(
f"Transfer request created: {tedad}x {product_obj.product_name} from Store #{s_room.id} to Store #{target_store.id} by User #{request.user.id}"
)
return redirect('/panel/store/' + str(s_room.id))
if mojodi_prd < tedad:
# تطابق ۱۰۰ درصدی پاسخ کدهای قدیمی با فرانت‌اند شما
return HttpResponse("موجودی کافی نیست، موجودی فعلی: "+ str(mojodi_prd))
# =====================================================================
# بخش دوم: ورود و خروج دستی (کنترل مستقیم با Transaction و Lock)
# =====================================================================
elif action_type in ['vorod', 'khoroj']:
with transaction.atomic():
# ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا
qs = ProductInStore.objects.filter(
Product=product_obj, StoreRoom=s_room, year=1400
)
# الف) خروج دستی ساده
if actionType == 'khoroj':
pInStore = ProductInStore(
StoreRoom=sRoom,
Product=productO,
year=1400
)
pInStore.s_out = tedad
pInStore.desc = "خروج: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc)
pInStore.movement_type = ProductInStore.MovementType.DIRECT_OUT
pInStore.save()
# قفل‌گذاری ردیف‌ها برای جلوگیری از Race Condition (در دیتابیس‌های پشتیبان)
if connection.features.has_select_for_update:
qs = qs.select_for_update()
# ب) انتقال بین دو انبار مجزا
elif actionType == 'transfer':
if not beanbar:
return HttpResponse("انبار مقصد مشخص نشده است.")
beanbarOjb = get_object_or_404(StoreRoom, id=_safe_int(beanbar))
# ردیف اول: ثبت تراکنش خروج از انبار مبدأ
pInStore = ProductInStore(
StoreRoom=sRoom,
Product=productO,
year=1400
)
pInStore.s_out = tedad
pInStore.desc = "خروج: "+str(tedad)+" از انبار " + str(sRoom.store_name) + " جهت انتقال به انبار " + str(beanbarOjb.store_name) + "#" + str(desc)
pInStore.movement_type = ProductInStore.MovementType.TRANSFER
pInStore.save()
locked_records = list(qs)
# ردیف دوم: ثبت تراکنش ورود به انبار مقصد (محافظت شده در بلاک اتمیک)
# --- الف) عملیات ورود دستی ---
if action_type == 'vorod':
ProductInStore.objects.create(
StoreRoom=beanbarOjb,
Product=productO,
StoreRoom=s_room,
Product=product_obj,
s_in=tedad,
s_out=0,
year=1400,
desc="ورود: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc),
movement_type=ProductInStore.MovementType.TRANSFER
desc=f"ورود: {tedad} به انبار {s_room.store_name}#{desc}",
movement_type=ProductInStore.MovementType.DIRECT_IN
)
# --- ب) عملیات خروج دستی ---
elif action_type == 'khoroj':
# محاسبه سریع موجودی در رم بدون فشار مجدد به دیتابیس
total_in = sum(item.s_in for item in locked_records if item.s_in)
total_out = sum(item.s_out for item in locked_records if item.s_out)
mojodi_prd = total_in - total_out
if mojodi_prd < tedad:
# تطابق ۱۰۰ درصدی با پاسخ متنی مورد انتظار فرانت‌اند
return HttpResponse("موجودی کافی نیست، موجودی فعلی: " + str(mojodi_prd))
ProductInStore.objects.create(
StoreRoom=s_room,
Product=product_obj,
s_in=0,
s_out=tedad,
year=1400,
desc=f"خروج: {tedad} از انبار {s_room.store_name}#{desc}",
movement_type=ProductInStore.MovementType.DIRECT_OUT
)
else:
return HttpResponseBadRequest("نوع عملیات نامعتبر است.")
except ValidationError as e:
# مدیریت خطاهای اعتبارسنجی (مثل کمبود موجودی در لایه سرویس یا یکسان بودن انبارها)
error_msg = ' '.join(e.messages) if hasattr(e, 'messages') else str(e)
logger.warning(f"Validation error in store operation: {error_msg}")
return HttpResponse(error_msg)
except PermissionDenied as e:
# مدیریت خطای دسترسی کاربر به انبار
logger.warning(f"Permission denied in store operation for user {request.user.id}: {str(e)}")
return HttpResponse("شما دسترسی لازم برای انجام این عملیات در این انبار را ندارید.", status=403)
except Exception as e:
logger.error(f"Error in store operation view: {str(e)}", exc_info=True)
return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.")
logger.error(f"Critical error in store operation view: {str(e)}", exc_info=True)
return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.", status=500)
return redirect('/panel/store/'+str(sRoom.id))
return redirect('/panel/store/' + str(s_room.id))
@login_required(login_url='/panel/p_login/')
def newFactor(request, c_id):