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
+2901 -123

No files matched your search

+31
View File
@@ -19,3 +19,34 @@ admin.site.register(Sarresid)
admin.site.register(Action) admin.site.register(Action)
admin.site.register(CacheTable) 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 import models
from django.db.models import Sum from django.db.models import Sum
# from django.db.models.signals import post_save, post_delete # from django.db.models.signals import post_save, post_delete
@@ -146,7 +147,12 @@ class Factor(models.Model):
self.save() self.save()
class ProductInStore(models.Model): class ProductInStore(models.Model):
class Meta:
permissions = [
("ViewProductInStore", "ViewProductInStore"),
("EditProductInStore", "EditProductInStore"),
("OnlyAnbar", "OnlyAnbar"),
]
class MovementType(models.TextChoices): class MovementType(models.TextChoices):
SALE = 'SALE', 'فروش عادی' SALE = 'SALE', 'فروش عادی'
WARRANTY = 'WARRANTY', 'گارانتی' WARRANTY = 'WARRANTY', 'گارانتی'
@@ -273,6 +279,92 @@ class ProductStockHistory(models.Model):
def __str__(self): def __str__(self):
return f"{self.product.product_name} in {self.store_room.store_name} - Stock: {self.current_stock}" 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 اصلاح کنید class FactorIteam(models.Model): # پیشنهاد: در آینده املای نام این کلاس را به FactorItem اصلاح کنید
factor = models.ForeignKey(Factor, on_delete=models.CASCADE) factor = models.ForeignKey(Factor, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True) 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
@@ -63,7 +63,6 @@
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'admin_panel:stores' %}" class="navbar-item"> <a href="{% url 'admin_panel:stores' %}" class="navbar-item">
انبار انبار
</a> </a>
@@ -99,10 +99,22 @@
<div class="tabs is-centered m-0"> <div class="tabs is-centered m-0">
<ul> <ul>
{% with url_name=request.resolver_match.url_name %} {% 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 == '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 == '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 %} {% endwith %}
</ul> </ul>
</div> </div>
+44 -36
View File
@@ -4,6 +4,7 @@
{% block storeBody %} {% 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"> <form class="columns m-0 is-multiline is-mobile" method="post">
{% csrf_token %} {% csrf_token %}
<div class="field column is-6-desktop is-6-mobile"> <div class="field column is-6-desktop is-6-mobile">
@@ -21,7 +22,6 @@
<option value="1">نقطه سفارش</option> <option value="1">نقطه سفارش</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
@@ -30,7 +30,6 @@
<input type="submit" class="button is-link is-fullwidth" value="جستجو"/> <input type="submit" class="button is-link is-fullwidth" value="جستجو"/>
</div> </div>
</div> </div>
</form> </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>
@@ -41,7 +40,9 @@
<tr> <tr>
<th style="min-width: 200px;">نام محصول</th> <th style="min-width: 200px;">نام محصول</th>
<th>قیمت</th> <th>قیمت</th>
<th>موجودی</th> <th>موجودی فیزیکی</th>
<th>در انتظار خروج (رزرو)</th>
<th>در انتظار ورود (در راه)</th>
<th style="min-width: 100px;">عملیات</th> <th style="min-width: 100px;">عملیات</th>
</tr> </tr>
</thead> </thead>
@@ -49,7 +50,9 @@
<tr> <tr>
<th>نام محصول</th> <th>نام محصول</th>
<th>قیمت</th> <th>قیمت</th>
<th>موجودی</th> <th>موجودی فیزیکی</th>
<th>در انتظار خروج (رزرو)</th>
<th>در انتظار ورود (در راه)</th>
<th>عملیات</th> <th>عملیات</th>
</tr> </tr>
</tfoot> </tfoot>
@@ -57,32 +60,46 @@
{% for prd in productSum %} {% for prd in productSum %}
<tr prd_id="{{ prd.id }}" class="openPrdEdit" p_cat="{{ prd.ProductCategory.id }}"> <tr prd_id="{{ prd.id }}" class="openPrdEdit" p_cat="{{ prd.ProductCategory.id }}">
<td> <td>
<a {% if perms.admin_panel.ViewStore %}href="{% url 'admin_panel:operationProduct' s_id prd.id %}"{% endif %}> <a {% if perms.admin_panel.OnlyAnbar %}href="{% url 'admin_panel:operationProduct' s_id prd.id %}"{% endif %}>
<span class="product_name">{{ prd.product_name }}</span> <span class="product_name font-weight-bold">{{ prd.product_name }}</span>
</a> </a>
</td> </td>
<td class="product_price">{{ prd.product_price }}</td> <td class="product_price">{{ prd.product_price }}</td>
<td class="product_mojodi" >{{ prd.mojodi }}</td>
<td class="product_mojodi has-text-weight-bold is-size-6">
{{ prd.mojodi }}
</td>
<td> <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>
{% if perms.admin_panel.ViewStore %} <td>
<span class="Oedit_prd"><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/edit.png' %}"></span> {% if prd.pending_in > 0 %}
<span class="tag is-info is-medium" title="ارسال شده به این انبار و منتظر تایید شما">
{{ prd.pending_in }} +
<span class="delet_prd"><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/delet.png' %}"></span> </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 %}"> <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> <span><img style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/add.png' %}"></span>
</a> </a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -98,9 +115,7 @@
</header> </header>
<form action="{% url 'admin_panel:productEdit' %}" id="formcsEdit" class="has-background-white brad5" method="post"> <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 %} {% csrf_token %}
<input id="i_pcode" type="hidden" name="prd_code" value=""> <input id="i_pcode" type="hidden" name="prd_code" value="">
<div id="userDT" class="is-hidden-mobile"> <div id="userDT" class="is-hidden-mobile">
@@ -111,11 +126,9 @@
</div> </div>
</div> </div>
<div class="field column is-12-desktop is-12-mobile m-0"> <div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">دسته کالا</label> <label class="label">دسته کالا</label>
<div class="control"> <div class="control">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="Editcategory" name="category" required> <select id="Editcategory" name="category" required>
<option value=""></option> <option value=""></option>
@@ -124,7 +137,6 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div>
</div> </div>
@@ -134,23 +146,17 @@
<input id="Editproduct_price" required value="" name="price" class="input an_name" type="text" placeholder="قیمت"> <input id="Editproduct_price" required value="" name="price" class="input an_name" type="text" placeholder="قیمت">
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<input value="ذخیره" type="submit" class="button is-success"> <input value="ذخیره" type="submit" class="button is-success">
<button class="button close_edit">لغو</button> <button type="button" class="button close_edit">لغو</button>
</footer> </footer>
</form> </form>
</div> </div>
</div> </div>
<script> <script>
{% if not mojodi %} {% if not mojodi %}
var mojodiVar = ''; var mojodiVar = '';
{% else %} {% else %}
@@ -159,25 +165,27 @@
var objpname = null; var objpname = null;
$('#mojodi').val(mojodiVar); $('#mojodi').val(mojodiVar);
$(".delet_prd").click(function(){ $(".delet_prd").click(function(){
var prd_id = $(this).parent().parent().attr('prd_id'); var prd_id = $(this).parent().parent().attr('prd_id');
if (confirm("از حذف مطمئن هستید؟")) { if (confirm("از حذف مطمئن هستید؟")) {
window.location.href = '/panel/product/del/' + prd_id + "/" + "{{ s_id }}"; window.location.href = '/panel/product/del/' + prd_id + "/" + "{{ s_id }}";
} }
}); });
$(".close_edit").click(function(){ $(".close_edit").click(function(){
$("#edit_prd").removeClass('is-active'); $("#edit_prd").removeClass('is-active');
}); });
$(".Oedit_prd").click(function(){ $(".Oedit_prd").click(function(){
$("#edit_prd").addClass('is-active'); $("#edit_prd").addClass('is-active');
prd_id = $(this).parent().parent().attr("prd_id"); var prd_id = $(this).parent().parent().attr("prd_id");
p_cat = $(this).parent().parent().attr("p_cat"); var p_cat = $(this).parent().parent().attr("p_cat");
objpname = $(this).parent().parent().find('.product_name') objpname = $(this).parent().parent().find('.product_name');
//alert($(this).parent().parent().find('.product_name'))
$("#i_pcode").val(prd_id); $("#i_pcode").val(prd_id);
$("#EditnamePrd").val( $(this).parent().parent().find('.product_name').html() ); $("#EditnamePrd").val($(this).parent().parent().find('.product_name').text().trim());
$("#Editproduct_price").val( $(this).parent().parent().find('.product_price').html() ); $("#Editproduct_price").val($(this).parent().parent().find('.product_price').text().trim());
$("#Editcategory").val(p_cat); $("#Editcategory").val(p_cat);
}); });
</script> </script>
@@ -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>/operations/<int:p_id>', views.operationProduct, name='operationProduct'),
path('store/<int:s_id>/operation/<int:p_id>/new', views.newOperation, name='newOperation'), path('store/<int:s_id>/operation/<int:p_id>/new', views.newOperation, name='newOperation'),
path('store/new', views.newStore, name='newStore'), 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/add', views.productAdd, name='productAdd'),
path('product/edit', views.productEdit, name='productEdit'), path('product/edit', views.productEdit, name='productEdit'),
path('operation', views.operation, name='operation'), path('operation', views.operation, name='operation'),
File diff suppressed because it is too large. Load diff
+240 -83
View File
@@ -4,9 +4,11 @@ from django.urls import reverse # تکراری در کد اصلی شما بود
from persiantools.jdatetime import JalaliDate from persiantools.jdatetime import JalaliDate
from persiantools import characters, digits from persiantools import characters, digits
import datetime import datetime
from django.contrib import messages
from django.views.decorators.http import require_POST
from django.utils import timezone # جدید from django.utils import timezone # جدید
from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage 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.csrf import csrf_exempt
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest # بخشBadRequest جدید 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 os
import json # جدید import json # جدید
import logging # جدید import logging # جدید
from .models import * from .models import *
from .services import create_transfer_request, approve_transfer_request, reject_transfer_request, cancel_transfer_request
logger = logging.getLogger(__name__) 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}) 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/') @login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def stores(request): def stores(request):
if request.user.username not in ('admin', 'office1', 'office2', 'office3', 'khatibi'): 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] 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} data = {'all_store': all_store}
return render(request, 'admin_panel/stores.html', data) 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/') @login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.OnlyAnbar', raise_exception=True)
def store(request, s_id): def store(request, s_id):
productCategory = ProductCategory.objects.all() productCategory = ProductCategory.objects.all()
storeO = StoreRoom.objects.get(id=s_id) storeO = get_object_or_404(StoreRoom, id=s_id)
searchStatus = False searchStatus = False
searchQS = request.POST.get('searchQS', '') searchQS = request.POST.get('searchQS', '').strip()
mojodi = request.POST.get('mojodi', '') mojodi = request.POST.get('mojodi', '')
#return HttpResponse(mojodi)
if searchQS !='': # ۱. محاسبه موجودی فیزیکی کل کالاها با یک کوئری تک‌ضرب و سریع GROUP BY
productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400,product_name__contains=searchQS).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') )) stock_balances = ProductInStore.objects.filter(
if mojodi == "0": StoreRoom=storeO, year=1400
productSum = productSum.filter(mojodi__lte=0) ).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 searchStatus = True
else:
productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') )) # 🛠️ اصلاح فرآیند ترتیب: استفاده از '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": if mojodi == "0":
products_list = [p for p in products_list if p.mojodi <= 0]
searchStatus = True searchStatus = True
productSum = productSum.filter(mojodi__lte=0) elif mojodi == "1":
data = {'storeO': storeO,'productSum': productSum, 's_id': s_id, 'searchStatus': searchStatus, "mojodi": mojodi, 'djsearchQS':searchQS, 'productCategory': productCategory} 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) return render(request, 'admin_panel/store.html', data)
@login_required(login_url='/panel/p_login/') @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): def operations(request, s_id):
page = request.GET.get('page', 1) page = request.GET.get('page', 1)
productCategory = ProductCategory.objects.all() productCategory = ProductCategory.objects.all()
@@ -942,7 +1004,7 @@ def operations(request, s_id):
@login_required(login_url='/panel/p_login/') @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): def operationProduct(request, s_id, p_id):
productCategory = ProductCategory.objects.all() productCategory = ProductCategory.objects.all()
storeO = get_object_or_404(StoreRoom, id=s_id) 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) return render(request, 'admin_panel/operations.html', data)
@login_required(login_url='/panel/p_login/') @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): def newOperation(request, s_id, p_id):
productCategory = ProductCategory.objects.all() productCategory = ProductCategory.objects.all()
storeO = StoreRoom.objects.get(id=s_id) 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} 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 ) 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/') @login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewStore', raise_exception=True) @permission_required('admin_panel.ViewStore', raise_exception=True)
def newStore(request): def newStore(request):
@@ -981,7 +1129,7 @@ def newStore(request):
return redirect('/panel/store/') return redirect('/panel/store/')
@login_required(login_url='/panel/p_login/') @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): def productAdd(request):
namePrd = request.POST.get('namePrd', '') namePrd = request.POST.get('namePrd', '')
category = request.POST.get('category', '') category = request.POST.get('category', '')
@@ -1031,109 +1179,118 @@ def productDel(request, p_id, s_id):
return redirect('/panel/store/'+str(s_id)) return redirect('/panel/store/'+str(s_id))
@login_required(login_url='/panel/p_login/') @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): def operation(request):
if request.method != 'POST': if request.method != 'POST':
return HttpResponseBadRequest("متد ارسالی نامعتبر است.") return HttpResponseBadRequest("متد ارسالی نامعتبر است.")
# استفاده از توابع امن اختصاصی پروژه شما برای جداسازی مقادیر عددی # استفاده از توابع امن اختصاصی پروژه برای جداسازی مقادیر عددی
prd_id = _safe_int(request.POST.get('prd_id')) 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')) tedad = _safe_int(request.POST.get('tedad'))
azanbar = _safe_int(request.POST.get('azanbar')) azanbar = _safe_int(request.POST.get('azanbar'))
beanbar = _safe_int(request.POST.get('beanbar')) beanbar = _safe_int(request.POST.get('beanbar'))
desc = request.POST.get('desc', '') desc = request.POST.get('desc', '').strip()
if tedad <= 0: if tedad <= 0:
return HttpResponseBadRequest("تعداد وارد شده باید بیشتر از صفر باشد.") return HttpResponseBadRequest("تعداد وارد شده باید بیشتر از صفر باشد.")
sRoom = get_object_or_404(StoreRoom, id=azanbar) s_room = get_object_or_404(StoreRoom, id=azanbar)
productO = get_object_or_404(Product, id=prd_id) product_obj = get_object_or_404(Product, id=prd_id)
try: try:
with transaction.atomic(): # =====================================================================
# بخش اول: انتقال بین انبارها (واگذاری کامل به لایه سرویس)
# =====================================================================
if action_type == 'transfer':
if not beanbar:
return HttpResponse("انبار مقصد مشخص نشده است.")
# ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا target_store = get_object_or_404(StoreRoom, id=_safe_int(beanbar))
qs = ProductInStore.objects.filter(
Product=productO, StoreRoom=sRoom, year=1400 # فراخوانی لایه سرویس برای ثبت درخواست انتقال (بدون تغییر مستقیم موجودی)
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}"
) )
# بررسی هوشمند پشتیبانی دیتابیس از قابلیت قفل ردیف (سازگار با SQLite و PostgreSQL) 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))
# =====================================================================
# بخش دوم: ورود و خروج دستی (کنترل مستقیم با Transaction و Lock)
# =====================================================================
elif action_type in ['vorod', 'khoroj']:
with transaction.atomic():
# ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا
qs = ProductInStore.objects.filter(
Product=product_obj, StoreRoom=s_room, year=1400
)
# قفل‌گذاری ردیف‌ها برای جلوگیری از Race Condition (در دیتابیس‌های پشتیبان)
if connection.features.has_select_for_update: if connection.features.has_select_for_update:
qs = qs.select_for_update() qs = qs.select_for_update()
# بارگذاری امن تراکنش‌ها در حافظه (RAM) جهت جلوگیری از خطای توابع تجمیعی همزمان با قفل دیتابیس
locked_records = list(qs) locked_records = list(qs)
# --- عملیات ورود دستی --- # --- الف) عملیات ورود دستی ---
if actionType == 'vorod': if action_type == 'vorod':
pInStore = ProductInStore( ProductInStore.objects.create(
StoreRoom=sRoom, StoreRoom=s_room,
Product=productO, Product=product_obj,
year=1400 s_in=tedad,
s_out=0,
year=1400,
desc=f"ورود: {tedad} به انبار {s_room.store_name}#{desc}",
movement_type=ProductInStore.MovementType.DIRECT_IN
) )
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']: elif action_type == 'khoroj':
# محاسبه سریع موجودی در رم بدون فشار مجدد به دیتابیس
# محاسبات دقیق و سریع موجودی کالا در سطح پایتون بدون درگیر کردن دیسک
total_in = sum(item.s_in for item in locked_records if item.s_in) 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) total_out = sum(item.s_out for item in locked_records if item.s_out)
mojodi_prd = total_in - total_out mojodi_prd = total_in - total_out
if mojodi_prd < tedad: if mojodi_prd < tedad:
# تطابق ۱۰۰ درصدی پاسخ کدهای قدیمی با فرانت‌اند شما # تطابق ۱۰۰ درصدی با پاسخ متنی مورد انتظار فرانت‌اند
return HttpResponse("موجودی کافی نیست، موجودی فعلی: " + str(mojodi_prd)) return HttpResponse("موجودی کافی نیست، موجودی فعلی: " + str(mojodi_prd))
# الف) خروج دستی ساده
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()
# ب) انتقال بین دو انبار مجزا
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()
# ردیف دوم: ثبت تراکنش ورود به انبار مقصد (محافظت شده در بلاک اتمیک)
ProductInStore.objects.create( ProductInStore.objects.create(
StoreRoom=beanbarOjb, StoreRoom=s_room,
Product=productO, Product=product_obj,
s_in=tedad, s_in=0,
s_out=tedad,
year=1400, year=1400,
desc="ورود: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc), desc=f"خروج: {tedad} از انبار {s_room.store_name}#{desc}",
movement_type=ProductInStore.MovementType.TRANSFER 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: except Exception as e:
logger.error(f"Error in store operation view: {str(e)}", exc_info=True) logger.error(f"Critical error in store operation view: {str(e)}", exc_info=True)
return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.") 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/') @login_required(login_url='/panel/p_login/')
def newFactor(request, c_id): def newFactor(request, c_id):