flowerstonezl преди 2 месеца
родител
ревизия
b5ef15e29f

+ 1 - 2
backup/urls.py

@@ -4,7 +4,6 @@ from . import views
 urlpatterns = [
     path('trigger/', views.trigger_backup, name='trigger_backup'),
     path('list/', views.list_backups, name='list_backups'),
-
     path('point/', views.restore_to_point, name='restore_to_point'),
-
+    path('shutdown/', views.shutdown_system, name='shutdown_system'),
 ]

+ 222 - 0
backup/views.py

@@ -12,6 +12,9 @@ import math
 from operation_log.views import log_success_operation, log_failure_operation
 from operation_log.models import OperationLog
 from django.utils import timezone
+import threading
+import sys
+import signal
 
 logger = logging.getLogger(__name__)
 
@@ -64,6 +67,126 @@ def scheduled_backup():
         except Exception as log_error:
             logger.error(f"定时备份失败日志记录失败: {str(log_error)}")
 
+def scheduled_consistency_fix():
+    """定时修复分组状态和托盘数一致性任务"""
+    try:
+        from location_statistics.views import LocationConsistencyChecker
+        
+        logger.info("开始执行定时一致性修复任务")
+        
+        fixed_groups = 0
+        error_groups = 0
+        fixed_details = 0
+        error_details = 0
+        group_fix_success = False
+        detail_fix_success = False
+        
+        # 执行分组状态修复
+        try:
+            group_checker = LocationConsistencyChecker(
+                warehouse_code=None,  # None表示检查所有仓库
+                layer=None,  # None表示检查所有楼层
+                auto_fix=True,  # 启用自动修复
+                fix_scope=['groups']  # 只修复分组状态
+            )
+            group_checker.check_all()
+            group_report = group_checker.generate_report()
+            
+            fixed_groups = group_report['summary']['fixed']['groups']
+            error_groups = group_report['summary']['errors_found']['groups']
+            group_fix_success = True
+            
+            logger.info(f"分组状态修复完成: 发现{error_groups}个问题,修复{fixed_groups}个")
+        except Exception as group_error:
+            logger.error(f"分组状态修复失败: {str(group_error)}", exc_info=True)
+        
+        # 执行托盘明细状态修复
+        try:
+            detail_checker = LocationConsistencyChecker(
+                warehouse_code=None,  # None表示检查所有仓库
+                layer=None,  # None表示检查所有楼层
+                auto_fix=True,  # 启用自动修复
+                fix_scope=['details']  # 只修复托盘明细状态
+            )
+            detail_checker.check_all()
+            detail_report = detail_checker.generate_report()
+            
+            fixed_details = detail_report['summary']['fixed']['details']
+            error_details = detail_report['summary']['errors_found']['details']
+            detail_fix_success = True
+            
+            logger.info(f"托盘明细状态修复完成: 发现{error_details}个问题,修复{fixed_details}个")
+        except Exception as detail_error:
+            logger.error(f"托盘明细状态修复失败: {str(detail_error)}", exc_info=True)
+        
+        # 记录操作日志
+        if group_fix_success and detail_fix_success:
+            operation_result = "success"
+            operation_content = f"定时一致性修复完成: 分组状态-发现{error_groups}个问题,修复{fixed_groups}个;托盘明细-发现{error_details}个问题,修复{fixed_details}个"
+        elif group_fix_success or detail_fix_success:
+            operation_result = "partial"
+            operation_content = f"定时一致性修复部分完成: "
+            if group_fix_success:
+                operation_content += f"分组状态-发现{error_groups}个问题,修复{fixed_groups}个;"
+            else:
+                operation_content += "分组状态修复失败;"
+            if detail_fix_success:
+                operation_content += f"托盘明细-发现{error_details}个问题,修复{fixed_details}个"
+            else:
+                operation_content += "托盘明细修复失败"
+        else:
+            operation_result = "failure"
+            operation_content = "定时一致性修复失败: 分组状态和托盘明细修复均失败"
+        
+        try:
+            OperationLog.objects.create(
+                operator="系统定时任务",
+                operation_content=operation_content,
+                operation_level="other",
+                operation_result=operation_result,
+                ip_address=None,
+                user_agent="系统定时任务",
+                request_method="CRON",
+                request_path="/backup/consistency_fix",
+                module_name="数据一致性修复"
+            )
+        except Exception as log_error:
+            logger.error(f"定时一致性修复日志记录失败: {str(log_error)}")
+            
+    except ImportError as e:
+        logger.warning(f"一致性修复模块未找到,跳过修复: {str(e)}")
+        try:
+            OperationLog.objects.create(
+                operator="系统定时任务",
+                operation_content=f"定时一致性修复失败: 模块未找到 - {str(e)}",
+                operation_level="other",
+                operation_result="failure",
+                ip_address=None,
+                user_agent="系统定时任务",
+                request_method="CRON",
+                request_path="/backup/consistency_fix",
+                module_name="数据一致性修复"
+            )
+        except Exception as log_error:
+            logger.error(f"定时一致性修复失败日志记录失败: {str(log_error)}")
+    except Exception as e:
+        logger.error(f"定时一致性修复失败: {str(e)}", exc_info=True)
+        # 记录失败日志
+        try:
+            OperationLog.objects.create(
+                operator="系统定时任务",
+                operation_content=f"定时一致性修复失败: {str(e)}",
+                operation_level="other",
+                operation_result="failure",
+                ip_address=None,
+                user_agent="系统定时任务",
+                request_method="CRON",
+                request_path="/backup/consistency_fix",
+                module_name="数据一致性修复"
+            )
+        except Exception as log_error:
+            logger.error(f"定时一致性修复失败日志记录失败: {str(log_error)}")
+
 # 启动定时备份(每6小时执行一次)
 if not scheduler.running:
     scheduler.add_job(
@@ -73,8 +196,17 @@ if not scheduler.running:
         minute=0,    # 在0分钟时执行
         id='db_backup_job'
     )
+    # 启动定时一致性修复任务(每6小时执行一次)
+    scheduler.add_job(
+        scheduled_consistency_fix,
+        'cron',
+        hour='*/6',  # 每2小时执行一次
+        minute=10,   # 在10分钟时执行(避免与备份任务冲突)
+        id='consistency_fix_job'
+    )
     scheduler.start()
     logger.info("定时备份任务已启动")
+    logger.info("定时一致性修复任务已启动")
 
 def get_backup_files(page=1, page_size=5):
     """获取备份文件列表(带分页)"""
@@ -290,3 +422,93 @@ def restore_to_point(request):
             'status': 'error',
             'message': str(e)
         }, status=500)
+
+def _shutdown_system(delay=5):
+    """延迟关闭系统"""
+    def shutdown():
+        logger.critical("系统正在关闭...")
+        try:
+            # 停止调度器
+            if scheduler.running:
+                scheduler.shutdown(wait=False)
+                logger.info("定时任务调度器已停止")
+        except Exception as e:
+            logger.error(f"停止调度器失败: {str(e)}")
+        
+        # 记录关闭日志
+        try:
+            OperationLog.objects.create(
+                operator="系统远程关闭",
+                operation_content="系统已通过远程接口关闭",
+                operation_level="critical",
+                operation_result="success",
+                ip_address=None,
+                user_agent="系统远程关闭接口",
+                request_method="SHUTDOWN",
+                request_path="/backup/shutdown",
+                module_name="系统管理"
+            )
+        except Exception as log_error:
+            logger.error(f"关闭日志记录失败: {str(log_error)}")
+        
+        # 强制退出
+        logger.critical("系统关闭完成,正在退出...")
+        os._exit(0)
+    
+    # 使用线程延迟执行关闭
+    timer = threading.Timer(delay, shutdown)
+    timer.daemon = True
+    timer.start()
+    logger.warning(f"系统将在 {delay} 秒后关闭")
+
+@csrf_exempt
+@require_POST
+def shutdown_system(request):
+    """远程关闭系统接口"""
+    try:
+        data = json.loads(request.body) if request.body else {}
+        delay = int(data.get('delay', 5))  # 默认5秒延迟
+        
+        # 验证延迟时间范围(1-60秒)
+        if delay < 1 or delay > 60:
+            delay = 5
+        
+        # 记录关闭请求日志
+        try:
+            operator_name = request.user.username if hasattr(request, 'user') and request.user.is_authenticated else "未知用户"
+            ip_address = request.META.get('REMOTE_ADDR', '未知IP')
+            log_success_operation(
+                request=request,
+                operation_content=f"远程关闭系统请求,延迟时间: {delay}秒",
+                operation_level="critical",
+                module_name="系统管理"
+            )
+        except Exception as log_error:
+            logger.error(f"关闭请求日志记录失败: {str(log_error)}")
+        
+        logger.critical(f"收到系统关闭请求,将在 {delay} 秒后关闭系统")
+        
+        # 启动延迟关闭
+        _shutdown_system(delay=delay)
+        
+        return JsonResponse({
+            'status': 'success',
+            'message': f'系统将在 {delay} 秒后关闭',
+            'delay': delay
+        })
+        
+    except Exception as e:
+        logger.error(f"处理系统关闭请求失败: {str(e)}", exc_info=True)
+        try:
+            log_failure_operation(
+                request=request,
+                operation_content=f"远程关闭系统请求失败: {str(e)}",
+                operation_level="critical",
+                module_name="系统管理"
+            )
+        except Exception as log_error:
+            logger.error(f"关闭失败日志记录失败: {str(log_error)}")
+        return JsonResponse({
+            'status': 'error',
+            'message': f'处理关闭请求失败: {str(e)}'
+        }, status=500)

+ 3 - 0
location_statistics/urls.py

@@ -7,4 +7,7 @@ urlpatterns = [
     path('group-statistics/', views.LocationGroupStatisticsView.as_view(), name='location-group-statistics'),
     path('refresh-statistics/', views.LocationStatisticsView.as_view(), name='refresh-location-statistics'),
     path('CheckView/', views.LocationConsistencyCheckView.as_view(), name='refresh-location-group-statistics'),
+    path('bind-container/', views.BindContainerView.as_view(), name='bind-container'),
+    path('update-location-status/', views.UpdateLocationStatusView.as_view(), name='update-location-status'),
+    path('unbind-container/', views.UnbindContainerView.as_view(), name='unbind-container'),
 ]

+ 255 - 0
location_statistics/views.py

@@ -756,3 +756,258 @@ def get_consistency_report(warehouse_code=None, layer=None, auto_fix=False):
     checker.check_all()
     return checker.generate_report()
 
+
+class BindContainerView(APIView):
+    """重新绑定托盘到库位"""
+    
+    def post(self, request):
+        location_code = request.data.get('location_code')
+        container_code = request.data.get('container_code')
+        
+        if not location_code:
+            return Response({
+                'success': False,
+                'message': '缺少库位编码参数'
+            }, status=status.HTTP_400_BAD_REQUEST)
+        
+        if not container_code:
+            return Response({
+                'success': False,
+                'message': '缺少托盘编码参数'
+            }, status=status.HTTP_400_BAD_REQUEST)
+        
+        try:
+            from bin.models import LocationModel, LocationContainerLink
+            from container.models import ContainerListModel
+            from bin.views import LocationAllocation
+            
+            # 验证库位是否存在
+            location = LocationModel.objects.filter(
+                location_code=location_code,
+                is_active=True
+            ).first()
+            
+            if not location:
+                return Response({
+                    'success': False,
+                    'message': f'库位 {location_code} 不存在或已禁用'
+                }, status=status.HTTP_404_NOT_FOUND)
+            
+            # 验证托盘是否存在
+            container = ContainerListModel.objects.filter(
+                container_code=container_code
+            ).first()
+            
+            if not container:
+                return Response({
+                    'success': False,
+                    'message': f'托盘 {container_code} 不存在'
+                }, status=status.HTTP_404_NOT_FOUND)
+            
+            # 使用 LocationAllocation 的方法来更新关联
+            allocator = LocationAllocation()
+            
+            # 先解除该库位的所有现有关联
+            LocationContainerLink.objects.filter(
+                location=location,
+                is_active=True
+            ).update(is_active=False)
+            
+            # 创建新的关联
+            with transaction.atomic():
+                # 检查是否已存在关联(即使是非活跃的)
+                existing_link = LocationContainerLink.objects.filter(
+                    location=location,
+                    container=container
+                ).first()
+                
+                if existing_link:
+                    # 如果存在,重新激活
+                    existing_link.is_active = True
+                    existing_link.save()
+                else:
+                    # 创建新关联
+                    LocationContainerLink.objects.create(
+                        location=location,
+                        container=container,
+                        is_active=True,
+                        operator=request.user.username if request.user.is_authenticated else 'system'
+                    )
+                
+                # 更新库位状态为占用
+                location.status = 'occupied'
+                location.save()
+                
+                # 更新库位组状态
+                allocator.update_location_group_status(location_code)
+            
+            logger.info(f"成功重新绑定托盘 {container_code} 到库位 {location_code}")
+            
+            return Response({
+                'success': True,
+                'code': '200',
+                'message': '重新绑定托盘成功',
+                'data': {
+                    'location_code': location_code,
+                    'container_code': container_code,
+                    'status': 'occupied'
+                }
+            }, status=status.HTTP_200_OK)
+            
+        except Exception as e:
+            logger.error(f"重新绑定托盘失败: {str(e)}", exc_info=True)
+            return Response({
+                'success': False,
+                'message': f'重新绑定托盘失败: {str(e)}'
+            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+class UpdateLocationStatusView(APIView):
+    """更新库位状态"""
+    
+    def post(self, request):
+        location_code = request.data.get('location_code')
+        location_status = request.data.get('status')  # 重命名变量避免与 status 模块冲突
+        
+        if not location_code:
+            return Response({
+                'success': False,
+                'message': '缺少库位编码参数'
+            }, status=status.HTTP_400_BAD_REQUEST)
+        
+        if not location_status:
+            return Response({
+                'success': False,
+                'message': '缺少状态参数'
+            }, status=status.HTTP_400_BAD_REQUEST)
+        
+        # 验证状态值
+        valid_statuses = ['available', 'occupied', 'disabled', 'reserved', 'maintenance']
+        if location_status not in valid_statuses:
+            return Response({
+                'success': False,
+                'message': f'无效的状态值,允许的值: {", ".join(valid_statuses)}'
+            }, status=status.HTTP_400_BAD_REQUEST)
+        
+        try:
+            from bin.models import LocationModel
+            from bin.views import LocationAllocation
+            
+            # 验证库位是否存在
+            location = LocationModel.objects.filter(
+                location_code=location_code,
+                is_active=True
+            ).first()
+            
+            if not location:
+                return Response({
+                    'success': False,
+                    'message': f'库位 {location_code} 不存在或已禁用'
+                }, status=status.HTTP_404_NOT_FOUND)
+            
+            # 保存旧状态
+            old_status = location.status
+            
+            # 使用 LocationAllocation 的方法来更新状态
+            allocator = LocationAllocation()
+            result = allocator.update_location_status(location_code, location_status, request=request)
+            
+            if result:
+                logger.info(f"成功更新库位 {location_code} 状态为 {location_status}")
+                
+                return Response({
+                    'success': True,
+                    'code': '200',
+                    'message': '更新库位状态成功',
+                    'data': {
+                        'location_code': location_code,
+                        'old_status': old_status,
+                        'new_status': location_status
+                    }
+                }, status=status.HTTP_200_OK)
+            else:
+                return Response({
+                    'success': False,
+                    'message': '更新库位状态失败'
+                }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+                
+        except Exception as e:
+            logger.error(f"更新库位状态失败: {str(e)}", exc_info=True)
+            return Response({
+                'success': False,
+                'message': f'更新库位状态失败: {str(e)}'
+            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+class UnbindContainerView(APIView):
+    """解除托盘与库位的绑定关系"""
+    
+    def post(self, request):
+        location_code = request.data.get('location_code')
+        
+        if not location_code:
+            return Response({
+                'success': False,
+                'message': '缺少库位编码参数'
+            }, status=status.HTTP_400_BAD_REQUEST)
+        
+        try:
+            from bin.models import LocationModel, LocationContainerLink
+            from bin.views import LocationAllocation
+            
+            # 验证库位是否存在
+            location = LocationModel.objects.filter(
+                location_code=location_code,
+                is_active=True
+            ).first()
+            
+            if not location:
+                return Response({
+                    'success': False,
+                    'message': f'库位 {location_code} 不存在或已禁用'
+                }, status=status.HTTP_404_NOT_FOUND)
+            
+            # 获取该库位的所有活跃关联
+            active_links = LocationContainerLink.objects.filter(
+                location=location,
+                is_active=True
+            )
+            
+            if not active_links.exists():
+                return Response({
+                    'success': False,
+                    'message': f'库位 {location_code} 没有绑定的托盘'
+                }, status=status.HTTP_400_BAD_REQUEST)
+            
+            # 解除所有关联
+            with transaction.atomic():
+                # 标记所有关联为非活跃
+                active_links.update(is_active=False)
+                
+                # 更新库位状态为可用
+                location.status = 'available'
+                location.save()
+                
+                # 更新库位组状态
+                allocator = LocationAllocation()
+                allocator.update_location_group_status(location_code)
+            
+            logger.info(f"成功解除库位 {location_code} 的托盘绑定")
+            
+            return Response({
+                'success': True,
+                'code': '200',
+                'message': '解除托盘绑定成功',
+                'data': {
+                    'location_code': location_code,
+                    'unbound_count': active_links.count(),
+                    'new_status': 'available'
+                }
+            }, status=status.HTTP_200_OK)
+            
+        except Exception as e:
+            logger.error(f"解除托盘绑定失败: {str(e)}", exc_info=True)
+            return Response({
+                'success': False,
+                'message': f'解除托盘绑定失败: {str(e)}'
+            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
templates/dist/spa/css/35.4ed880df.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
templates/dist/spa/css/35.da66b954.css


Файловите разлики са ограничени, защото са твърде много
+ 1 - 1
templates/dist/spa/index.html


Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
templates/dist/spa/js/35.a938d86d.js


BIN
templates/dist/spa/js/35.a938d86d.js.gz


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
templates/dist/spa/js/35.e9d60c9f.js


BIN
templates/dist/spa/js/35.e9d60c9f.js.gz


Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
templates/dist/spa/js/37.e70b8a57.js


BIN
templates/dist/spa/js/37.e70b8a57.js.gz


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
templates/dist/spa/js/37.f9bec936.js


BIN
templates/dist/spa/js/37.f9bec936.js.gz


BIN
templates/dist/spa/js/app.86855383.js.gz


Файловите разлики са ограничени, защото са твърде много
+ 1 - 1
templates/dist/spa/js/app.86855383.js


BIN
templates/dist/spa/js/app.ba0fbeb1.js.gz


Файловите разлики са ограничени, защото са твърде много
+ 2 - 2
templates/dist/spa/js/vendor.7897d76a.js


BIN
templates/dist/spa/js/vendor.7897d76a.js.gz


+ 451 - 1
templates/src/layouts/MainLayout.vue

@@ -39,6 +39,29 @@
             >{{ "保存数据库" }}</q-tooltip
           ></q-btn
         >
+        <q-btn 
+          flat 
+          @click="showLocationErrorsDialog = true" 
+          round 
+          dense 
+          icon="mail"
+          style="position: relative"
+        >
+          <q-badge
+            v-if="locationErrorsCount > 0"
+            color="red"
+            floating
+            rounded
+            :label="locationErrorsCount > 99 ? '99+' : locationErrorsCount"
+          />
+          <q-tooltip
+            content-class="bg-amber text-black shadow-4"
+            :offset="[15, 15]"
+            content-style="font-size: 12px"
+          >
+            位置异常通知 ({{ locationErrorsCount }})
+          </q-tooltip>
+        </q-btn>
         <screenfull id="screenfull" class="right-menu-item hover-effect" />
         <transition appear enter-active-class="animated zoomIn">
           <q-btn
@@ -690,6 +713,174 @@
         </q-card-section>
       </q-card>
     </q-dialog>
+
+    <!-- 位置错误通知对话框 -->
+    <q-dialog v-model="showLocationErrorsDialog" maximized>
+      <q-card>
+        <q-card-section class="row items-center q-pb-none">
+          <div class="text-h6">位置异常通知</div>
+          <q-space />
+          <q-btn icon="close" flat round dense v-close-popup />
+        </q-card-section>
+
+        <q-card-section>
+          <q-list v-if="locationErrors.length > 0" separator>
+            <q-expansion-item
+              v-for="(error, index) in locationErrors"
+              :key="index"
+              :label="`${error.location_code} - ${error.error_type_display}`"
+              :caption="`检测时间: ${formatErrorDateTime(error.detected_at)}`"
+              header-class="text-primary"
+            >
+              <q-card>
+                <q-card-section>
+                  <div class="row q-mb-md">
+                    <div class="col-6">
+                      <div class="text-caption text-grey">位置编码</div>
+                      <div class="text-body1">{{ error.location_code }}</div>
+                    </div>
+                    <div class="col-3">
+                      <div class="text-caption text-grey">层/行/列</div>
+                      <div class="text-body1">{{ error.layer }}/{{ error.row }}/{{ error.col }}</div>
+                    </div>
+                    <div class="col-3">
+                      <div class="text-caption text-grey">当前状态</div>
+                      <div class="text-body1">{{ error.current_status }}</div>
+                    </div>
+                  </div>
+
+                  <q-separator class="q-my-md" />
+
+                  <!-- 错误类型1: 库位占用但没有绑定托盘 -->
+                  <div v-if="error.error_type === 'occupied_no_container'">
+                    <div class="text-subtitle2 q-mb-sm">
+                      <q-icon name="warning" color="orange" class="q-mr-xs" />
+                      库位状态为占用,但未绑定托盘
+                    </div>
+                    <div class="q-mb-md">
+                      <q-radio
+                        v-model="error.userAction"
+                        val="has_container"
+                        label="确认该库位有托盘"
+                      />
+                      <q-radio
+                        v-model="error.userAction"
+                        val="no_container"
+                        label="确认该库位没有托盘"
+                      />
+                    </div>
+
+                    <div v-if="error.userAction === 'has_container'" class="q-mb-md">
+                      <q-input
+                        v-model="error.container_code"
+                        label="请输入托盘码"
+                        outlined
+                        dense
+                        class="q-mb-sm"
+                      />
+                      <q-btn
+                        color="primary"
+                        label="重新绑定托盘"
+                        @click="rebindContainer(error)"
+                        :loading="error.processing"
+                      />
+                    </div>
+
+                    <div v-if="error.userAction === 'no_container'" class="q-mb-md">
+                      <q-btn
+                        color="primary"
+                        label="修改库位状态为可用"
+                        @click="updateLocationStatus(error, 'available')"
+                        :loading="error.processing"
+                      />
+                    </div>
+                  </div>
+
+                  <!-- 错误类型2: 库位可用但有托盘绑定 -->
+                  <div v-if="error.error_type === 'available_with_container'">
+                    <div class="text-subtitle2 q-mb-sm">
+                      <q-icon name="warning" color="orange" class="q-mr-xs" />
+                      库位状态为可用,但有托盘绑定
+                    </div>
+                    <div class="q-mb-md">
+                      <q-radio
+                        v-model="error.userAction"
+                        val="has_container"
+                        label="确认该库位有托盘"
+                      />
+                      <q-radio
+                        v-model="error.userAction"
+                        val="no_container"
+                        label="确认该库位没有托盘"
+                      />
+                    </div>
+
+                    <div v-if="error.userAction === 'has_container'" class="q-mb-md">
+                      <q-btn
+                        color="primary"
+                        label="修改库位状态为占用"
+                        @click="updateLocationStatus(error, 'occupied')"
+                        :loading="error.processing"
+                      />
+                    </div>
+
+                    <div v-if="error.userAction === 'no_container'" class="q-mb-md">
+                      <q-btn
+                        color="primary"
+                        label="解除托盘绑定关系"
+                        @click="unbindContainer(error)"
+                        :loading="error.processing"
+                      />
+                    </div>
+                  </div>
+
+                  <!-- 辅助查询信息 -->
+                  <q-separator class="q-my-md" />
+                  <div class="text-subtitle2 q-mb-sm">
+                    <q-icon name="search" color="primary" class="q-mr-xs" />
+                    辅助查询 - 该位置的托盘信息
+                  </div>
+                  <q-btn
+                    color="secondary"
+                    label="查询该位置的托盘"
+                    @click="queryContainersByLocation(error)"
+                    :loading="error.querying"
+                    class="q-mb-sm"
+                  />
+                  <q-table
+                    v-if="error.containers && error.containers.length > 0"
+                    :data="error.containers"
+                    :columns="containerColumns"
+                    row-key="id"
+                    flat
+                    bordered
+                    dense
+                    class="q-mt-sm"
+                  />
+                  <div v-else-if="error.containers && error.containers.length === 0" class="text-caption text-grey q-mt-sm">
+                    未找到该位置的托盘
+                  </div>
+                </q-card-section>
+              </q-card>
+            </q-expansion-item>
+          </q-list>
+          <div v-else class="text-center q-pa-lg text-grey">
+            暂无位置异常
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right">
+          <q-btn flat label="关闭" v-close-popup />
+          <q-btn
+            flat
+            label="刷新"
+            color="primary"
+            @click="refreshLocationErrors"
+            :loading="loadingLocationErrors"
+          />
+        </q-card-actions>
+      </q-card>
+    </q-dialog>
   </q-layout>
 </template>
 <script>
@@ -750,7 +941,32 @@ export default {
       pollInterval: null,
       timer: null,
       selectedRole: '',
-      permissions: [] // 存储权限数据
+      permissions: [], // 存储权限数据
+      showLocationErrorsDialog: false,
+      locationErrors: [],
+      locationErrorsCount: 0,
+      loadingLocationErrors: false,
+      containerColumns: [
+        {
+          name: 'container_code',
+          label: '托盘码',
+          field: 'container_code',
+          align: 'left'
+        },
+        {
+          name: 'current_location',
+          label: '当前位置',
+          field: 'current_location',
+          align: 'left'
+        },
+        {
+          name: 'target_location',
+          label: '目标位置',
+          field: 'target_location',
+          align: 'left'
+        }
+
+      ]
     }
   },
   methods: {
@@ -1029,6 +1245,238 @@ export default {
       getauth('/wms/outboundBills/?bound_status=0').then((res) => {
         this.ERPOutTasks = res.count
       })
+      // 检查位置错误
+      // this.checkLocationErrors()
+    },
+    // 检查位置错误
+    checkLocationErrors () {
+      // const layer = 1 // 默认检查第一层,可以根据需要调整
+      const url = `/location_statistics/CheckView/`
+      const payload = {}
+      if (localStorage.getItem('warehouse_code')) {
+        payload.warehouse_code = localStorage.getItem('warehouse_code')
+      }
+
+      postauth(url, payload)
+        .then((res) => {
+          if (res && res.data) {
+            if (res.data.success === false) {
+              this.locationErrors = []
+              this.locationErrorsCount = 0
+              return
+            }
+            const checkResult = res.data.data || res.data
+            if (checkResult && checkResult.details && checkResult.details.location_errors) {
+              this.locationErrors = checkResult.details.location_errors.map(err => ({
+                ...err,
+                userAction: null,
+                container_code: '',
+                processing: false,
+                querying: false,
+                containers: null,
+                error_type_display: this.getErrorTypeDisplay(err),
+                error_type: this.determineErrorType(err)
+              }))
+              this.locationErrorsCount = this.locationErrors.length
+            } else {
+              this.locationErrors = []
+              this.locationErrorsCount = 0
+            }
+          } else {
+            this.locationErrors = []
+            this.locationErrorsCount = 0
+          }
+        })
+        .catch((err) => {
+          console.error('检查位置错误失败', err)
+          this.locationErrors = []
+          this.locationErrorsCount = 0
+        })
+    },
+    // 确定错误类型
+    determineErrorType (error) {
+      // 根据当前状态和期望状态判断错误类型
+      // 类型1: 库位占用但没有绑定托盘
+      // current_status 为 occupied/reserved,但 expected_status 为 available(表示应该没有托盘)
+      if ((error.current_status === 'occupied' || error.current_status === 'reserved') && 
+          (error.expected_status === 'available' || !error.expected_status)) {
+        return 'occupied_no_container'
+      }
+      // 类型2: 库位可用但有托盘绑定
+      // current_status 为 available,但 expected_status 为 occupied(表示应该有托盘)
+      if (error.current_status === 'available' && 
+          (error.expected_status === 'occupied' || error.expected_status === 'reserved')) {
+        return 'available_with_container'
+      }
+      // 如果没有明确的状态,根据当前状态判断
+      if (error.current_status === 'occupied' || error.current_status === 'reserved') {
+        return 'occupied_no_container'
+      }
+      return 'available_with_container'
+    },
+    // 刷新位置错误
+    refreshLocationErrors () {
+      this.loadingLocationErrors = true
+      this.checkLocationErrors()
+      setTimeout(() => {
+        this.loadingLocationErrors = false
+      }, 1000)
+    },
+    // 获取错误类型显示文本
+    getErrorTypeDisplay (error) {
+      const errorType = this.determineErrorType(error)
+      if (errorType === 'occupied_no_container') {
+        return '库位占用但未绑定托盘'
+      } else if (errorType === 'available_with_container') {
+        return '库位可用但有托盘绑定'
+      }
+      return error.error_type || '未知错误'
+    },
+    // 格式化错误时间
+    formatErrorDateTime (dateStr) {
+      if (!dateStr) return '-'
+      if (typeof dateStr === 'string' && dateStr.includes('T')) {
+        return dateStr.replace('T', ' ').substring(0, 19)
+      }
+      return dateStr
+    },
+    // 重新绑定托盘
+    rebindContainer (error) {
+      if (!error.container_code || !error.container_code.trim()) {
+        this.$q.notify({
+          type: 'negative',
+          message: '请输入托盘码'
+        })
+        return
+      }
+
+      error.processing = true
+      const url = '/location_statistics/bind-container/'
+      const payload = {
+        location_code: error.location_code,
+        container_code: error.container_code.trim()
+      }
+
+      postauth(url, payload)
+        .then((res) => {
+          if (res && (res.code === '200' || res.success)) {
+            this.$q.notify({
+              type: 'positive',
+              message: '重新绑定托盘成功'
+            })
+            // 从列表中移除该错误并刷新
+            this.checkLocationErrors()
+          } else {
+            this.$q.notify({
+              type: 'negative',
+              message: res?.msg || res?.message || '重新绑定托盘失败'
+            })
+          }
+        })
+        .catch((err) => {
+          this.$q.notify({
+            type: 'negative',
+            message: '重新绑定托盘失败: ' + (err?.message || err?.detail || '未知错误')
+          })
+        })
+        .finally(() => {
+          error.processing = false
+        })
+    },
+    // 更新库位状态
+    updateLocationStatus (error, status) {
+      error.processing = true
+      const url = '/location_statistics/update-location-status/'
+      const payload = {
+        location_code: error.location_code,
+        status: status
+      }
+
+      postauth(url, payload)
+        .then((res) => {
+          if (res && (res.code === '200' || res.success)) {
+            this.$q.notify({
+              type: 'positive',
+              message: '更新库位状态成功'
+            })
+            // 刷新错误列表
+            this.checkLocationErrors()
+          } else {
+            this.$q.notify({
+              type: 'negative',
+              message: res?.msg || res?.message || '更新库位状态失败'
+            })
+          }
+        })
+        .catch((err) => {
+          this.$q.notify({
+            type: 'negative',
+            message: '更新库位状态失败: ' + (err?.message || err?.detail || '未知错误')
+          })
+        })
+        .finally(() => {
+          error.processing = false
+        })
+    },
+    // 解除托盘绑定
+    unbindContainer (error) {
+      error.processing = true
+      const url = '/location_statistics/unbind-container/'
+      const payload = {
+        location_code: error.location_code
+      }
+
+      postauth(url, payload)
+        .then((res) => {
+          if (res && (res.code === '200' || res.success)) {
+            this.$q.notify({
+              type: 'positive',
+              message: '解除托盘绑定成功'
+            })
+            // 刷新错误列表
+            this.checkLocationErrors()
+          } else {
+            this.$q.notify({
+              type: 'negative',
+              message: res?.msg || res?.message || '解除托盘绑定失败'
+            })
+          }
+        })
+        .catch((err) => {
+          this.$q.notify({
+            type: 'negative',
+            message: '解除托盘绑定失败: ' + (err?.message || err?.detail || '未知错误')
+          })
+        })
+        .finally(() => {
+          error.processing = false
+        })
+    },
+    // 查询该位置的托盘
+    queryContainersByLocation (error) {
+      error.querying = true
+      // 拼接当前位置的库位码 W01-row(两位数字)-col(两位数字)-layer(一位数字)
+      const current_location_code = `W01-${error.row.toString().padStart(2, '0')}-${error.col.toString().padStart(2, '0')}-${error.layer.toString().padStart(2, '0')}`
+      const url = `/container/list/?current_location__icontains=${current_location_code}`
+      getauth(url)
+        .then((res) => {
+          if (res && res.results) {
+            error.containers = res.results
+          } else {
+            error.containers = []
+          }
+        })
+        .catch((err) => {
+          console.error('查询托盘失败', err)
+          error.containers = []
+          this.$q.notify({
+            type: 'negative',
+            message: '查询托盘失败: ' + (err?.message || '未知错误')
+          })
+        })
+        .finally(() => {
+          error.querying = false
+        })
     }
   },
   created () {
@@ -1066,6 +1514,8 @@ export default {
       _this.isLoggedIn()
     })
     _this.loadRolePermissions()
+    // 初始检查位置错误
+    _this.checkLocationErrors()
 
     _this.timer = setInterval(() => {
       _this.handleTimer()

+ 90 - 6
templates/src/pages/task/task.vue

@@ -20,7 +20,7 @@
         <template v-slot:header-cell="props">
           <q-th :props="props" @dblclick="handleHeaderDblClick(props.col)">
             <!-- 为特定列添加下拉选择器 -->
-            <template v-if="['bound_department'].includes(props.col.name)">
+            <template v-if="['bound_department', 'tasktype'].includes(props.col.name)">
               <q-select
                 dense
                 outlined
@@ -54,6 +54,23 @@
                 >{{ $t("refreshtip") }}</q-tooltip
               >
             </q-btn>
+            <q-toggle
+              v-model="enableWcsLocationQuery"
+              checked-icon="check"
+              unchecked-icon="close"
+              :label="enableWcsLocationQuery ? 'WCS状态查询:开启' : 'WCS状态查询:关闭'"
+              size="sm"
+              color="primary"
+              @update:model-value="handleWcsQueryToggle"
+            >
+              <q-tooltip
+                content-class="bg-amber text-black shadow-4"
+                :offset="[10, 10]"
+                content-style="font-size: 12px"
+              >
+                {{ enableWcsLocationQuery ? '已开启WCS库位状态查询' : '已关闭WCS库位状态查询' }}
+              </q-tooltip>
+            </q-toggle>
           </q-btn-group>
 
           <q-space />
@@ -567,6 +584,7 @@ export default {
         { label: '盘点状态', value: 'Inventory' }
       ],
       wcsLocationApiBase: 'https://mock.apipost.net/mock/406ff305c801000/wcs/',
+      enableWcsLocationQuery: false, // 默认关闭WCS库位状态查询
 
       table_list: [],
 
@@ -691,7 +709,8 @@ export default {
       total: 0,
       paginationIpt: 1,
       filterModels: {
-        bound_department: null
+        bound_department: null,
+        tasktype: null
       },
       filterdata: {},
       activeSearchField: '',
@@ -754,6 +773,9 @@ export default {
       if (col.name === 'priority') {
         return this.resolvePriorityValue(row)
       }
+      if (col.name === 'tasktype') {
+        return this.getTaskTypeLabel(baseValue)
+      }
       if (baseValue === null || baseValue === undefined || baseValue === '') {
         return '-'
       }
@@ -992,10 +1014,11 @@ export default {
       const normalized = this.normalizeTaskType(value)
       const mapping = {
         inbound: '入库',
-        putaway: '上架',
-        move: '移库',
+        // putaway: '上架',
+        // move: '移库',
         outbound: '出库',
-        out: '出库'
+        // out: '出库',
+        check: '抽检'
       }
       return mapping[normalized] || value || '-'
     },
@@ -1164,6 +1187,14 @@ export default {
           ]
         case 'bound_department':
           return this.bound_department_list
+        case 'tasktype':
+          return [
+            { label: '入库', value: 'inbound' },
+            { label: '出库', value: 'outbound' },
+            { label: '抽检', value: 'check' }
+            // { label: '上架', value: 'putaway' },
+            // { label: '移库', value: 'move' }
+          ]
         default:
           return []
       }
@@ -1400,7 +1431,8 @@ export default {
       var _this = this
       this.filterdata = {}
       this.filterModels = {
-        bound_department: null
+        bound_department: null,
+        tasktype: null
       }
       _this.getSearchList()
     },
@@ -1534,6 +1566,10 @@ export default {
       }
     },
     async loadLocationStatuses () {
+      // 如果未启用WCS查询,直接返回
+      if (!this.enableWcsLocationQuery) {
+        return
+      }
       if (!Array.isArray(this.table_list)) return
       for (const row of this.table_list) {
         // 查询起始位置状态
@@ -1560,6 +1596,47 @@ export default {
       // 加载完成后,自动校验工作状态为1的任务
       this.validateWorkingStatusTasks()
     },
+    handleWcsQueryToggle (value) {
+      // 保存状态到 LocalStorage
+      LocalStorage.set('enableWcsLocationQuery', value)
+      if (value) {
+        // 如果开启,立即加载一次库位状态
+        this.loadLocationStatuses()
+        this.$q.notify({
+          type: 'positive',
+          message: '已开启WCS库位状态查询',
+          icon: 'check',
+          timeout: 2000
+        })
+      } else {
+        // 如果关闭,清除已查询的状态
+        this.clearLocationStatuses()
+        this.$q.notify({
+          type: 'info',
+          message: '已关闭WCS库位状态查询',
+          icon: 'info',
+          timeout: 2000
+        })
+      }
+    },
+    clearLocationStatuses () {
+      // 清除所有已查询的库位状态
+      if (!Array.isArray(this.table_list)) return
+      for (const row of this.table_list) {
+        if (row.current_location_status !== undefined) {
+          this.$set(row, 'current_location_status', null)
+        }
+        if (row.current_location_data !== undefined) {
+          this.$set(row, 'current_location_data', null)
+        }
+        if (row.target_location_status !== undefined) {
+          this.$set(row, 'target_location_status', null)
+        }
+        if (row.target_location_data !== undefined) {
+          this.$set(row, 'target_location_data', null)
+        }
+      }
+    },
     validateWorkingStatusTasks () {
       // 只校验工作状态为1的任务
       if (!Array.isArray(this.table_list)) return
@@ -1861,6 +1938,13 @@ export default {
       _this.login_name = ''
       LocalStorage.set('login_name', '')
     }
+    // 从 LocalStorage 读取 WCS 查询开关状态,默认关闭
+    if (LocalStorage.has('enableWcsLocationQuery')) {
+      _this.enableWcsLocationQuery = LocalStorage.getItem('enableWcsLocationQuery') === true || LocalStorage.getItem('enableWcsLocationQuery') === 'true'
+    } else {
+      _this.enableWcsLocationQuery = false
+      LocalStorage.set('enableWcsLocationQuery', false)
+    }
     if (LocalStorage.has('auth')) {
       const timeStamp = Date.now()
       const formattedString = date.formatDate(timeStamp, 'YYYY/MM/DD')