flowerstonezl 2 mesiacov pred
rodič
commit
893abefd29

+ 11 - 0
bin/serializers.py

@@ -50,6 +50,17 @@ class LocationGroupPostSerializer(serializers.ModelSerializer):
             'current_batch': {'required': False, 'allow_blank': True},
         }
 
+class LocationContainerLinkSerializer(serializers.ModelSerializer):
+    """库位-托盘关联序列化器"""
+    container = ContainerSimpleSerializer(read_only=True)
+    container_code = serializers.CharField(source='container.container_code', read_only=True)
+    location_code = serializers.CharField(source='location.location_code', read_only=True)
+    
+    class Meta:
+        model = LocationContainerLink
+        fields = ['id', 'location', 'location_code', 'container', 'container_code', 'is_active', 'put_time', 'operator']
+        read_only_fields = ['id', 'put_time']
+
 
 
 

+ 4 - 0
bin/urls.py

@@ -10,6 +10,10 @@ urlpatterns = [
     path (r'group/check-pallet-consistency/', views.locationGroupViewSet.as_view({"get": "check_pallet_consistency"}), name='location_group_check_pallet_consistency'),
     path (r'group/fix-pallet-consistency/', views.locationGroupViewSet.as_view({"post": "fix_pallet_consistency"}), name='location_group_fix_pallet_consistency'),
     re_path(r'^group/(?P<pk>\d+)/$',  views.locationGroupViewSet.as_view({"get": "retrieve", "put": "update"}), name='location_detail'),
+    
+    # 库位-托盘关联查询
+    path (r'link/', views.locationContainerLinkViewSet.as_view({"get": "list"}), name='location_container_link_list'),
+    re_path(r'^link/(?P<pk>\d+)/$', views.locationContainerLinkViewSet.as_view({"get": "retrieve"}), name='location_container_link_detail'),
     # path(r'management/', views.stockshelfViewSet.as_view({"get": "list", "post": "create"}), name="management"),
     # re_path(r'^management/(?P<pk>\d+)/$', views.stockshelfViewSet.as_view({'get': 'retrieve','put': 'update','patch': 'partial_update','delete': 'destroy'}), name="staff_1"),
 

+ 83 - 1
bin/views.py

@@ -18,7 +18,7 @@ from bound.models import BoundBatchModel,BoundDetailModel,BoundListModel
 
 from .filter import DeviceFilter,LocationFilter,LocationContainerLinkFilter,LocationChangeLogFilter,LocationGroupFilter
 from .serializers import LocationListSerializer,LocationPostSerializer
-from .serializers import LocationGroupListSerializer,LocationGroupPostSerializer
+from .serializers import LocationGroupListSerializer,LocationGroupPostSerializer,LocationContainerLinkSerializer
 # 以后添加模块时,只需要在这里添加即可
 from rest_framework.permissions import AllowAny
 from container.models import ContainerListModel,ContainerDetailModel,ContainerOperationModel,TaskModel
@@ -329,7 +329,89 @@ class locationViewSet(viewsets.ModelViewSet):
         )
         
         return Response(data, status=200)
+
+class locationContainerLinkViewSet(viewsets.ReadOnlyModelViewSet):
+    """
+    库位-托盘关联查询 ViewSet
+    retrieve:
+        获取单个关联详情(get)
+    list:
+        获取关联列表(all)
+    """
+    pagination_class = MyPageNumberPagination
+    filter_backends = [DjangoFilterBackend, OrderingFilter]
+    ordering_fields = ['id', 'put_time', 'create_time', 'update_time']
+    filter_class = LocationContainerLinkFilter
+    serializer_class = LocationContainerLinkSerializer
+
+    def get_queryset(self):
+        # 检查认证,使用与其他 ViewSet 相同的方式
+        if not self.request.auth:
+            logger.warning("未认证用户尝试访问库位-托盘关联列表")
+            return LocationContainerLink.objects.none()
+        
+        queryset = LocationContainerLink.objects.select_related(
+            'location', 'container'
+        ).all()
+        
+        # 获取查询参数
+        location_code = self.request.query_params.get('location_code', None)
+        container_code = self.request.query_params.get('container_code', None)
+
+        is_active_param = self.request.query_params.get('is_active', 'true')
+
+        # 支持通过 location_code 查询
+        if location_code:  
+            if len(location_code.split('-')) == 4:
+                location_row = location_code.split('-')[1]
+                location_col = location_code.split('-')[2]
+                location_layer = location_code.split('-')[3]
+                queryset = queryset.filter(location__row=location_row, location__col=location_col, location__layer=location_layer)
+            else:
+                queryset = queryset.filter(location__location_code=location_code)
+            logger.debug(f"按 location_code 过滤: {location_code}")
+        
+        # 支持通过 container_code 查询
+        if container_code:
+            queryset = queryset.filter(container__container_code=container_code)
+            logger.debug(f"按 container_code 过滤: {container_code}")
+        
+        # 处理 is_active 参数
+        if is_active_param:
+            is_active_str = str(is_active_param).lower().strip()
+            if is_active_str in ('true', '1', 'yes'):
+                queryset = queryset.filter(is_active=True)
+            elif is_active_str in ('false', '0', 'no'):
+                queryset = queryset.filter(is_active=False)
+            # 如果参数值不是预期的,默认返回激活的
+            else:
+                queryset = queryset.filter(is_active=True)
+        else:
+            # 如果没有指定 is_active 参数,默认只返回激活的
+            queryset = queryset.filter(is_active=True)
+        
+        # 添加调试日志
+        count = queryset.count()
+        logger.info(f"查询库位-托盘关联: location_code={location_code}, container_code={container_code}, is_active={is_active_param}, 结果数量={count}")
+        
+        # 如果结果为空,记录更详细的信息用于调试
+        if count == 0:
+            total_count = LocationContainerLink.objects.count()
+            active_count = LocationContainerLink.objects.filter(is_active=True).count()
+            logger.debug(f"查询结果为空。数据库总记录数: {total_count}, 激活记录数: {active_count}")
+            if location_code:
+                location_exists = LocationModel.objects.filter(location_code=location_code).exists()
+                logger.debug(f"库位 {location_code} 是否存在: {location_exists}")
+        
+        log_operation(
+            request=self.request,
+            operation_content=f"查看库位-托盘关联列表 (location_code={location_code}, container_code={container_code}, 结果数={count})",
+            operation_level="view",
+            operator=self.request.auth.name if self.request.auth else None,
+            module_name="库位关联"
+        )
         
+        return queryset
 
 class locationGroupViewSet(viewsets.ModelViewSet):
     """

+ 117 - 42
location_statistics/views.py

@@ -780,13 +780,22 @@ class BindContainerView(APIView):
             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 len(location_code.split('-')) == 4:
+                location_row = location_code.split('-')[1]
+                location_col = location_code.split('-')[2]
+                location_layer = location_code.split('-')[3]
+                location = LocationModel.objects.filter(
+                    row=location_row,
+                    col=location_col,
+                    layer=location_layer,
+                    is_active=True).first()
+            else:
+                location = LocationModel.objects.filter(
+                    location_code=location_code,
+                    is_active=True
+                ).first()
+
+        
             if not location:
                 return Response({
                     'success': False,
@@ -831,7 +840,8 @@ class BindContainerView(APIView):
                         location=location,
                         container=container,
                         is_active=True,
-                        operator=request.user.username if request.user.is_authenticated else 'system'
+                        operator=request.auth.name if request.auth else None,
+         
                     )
                 
                 # 更新库位状态为占用
@@ -944,6 +954,7 @@ class UnbindContainerView(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({
@@ -967,43 +978,107 @@ class UnbindContainerView(APIView):
                     '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():
+            # 根据是否提供 container_code 来决定解除范围
+            if container_code:
+                # 只解除指定托盘的绑定
+                from container.models import ContainerListModel
+                container = ContainerListModel.objects.filter(
+                    container_code=container_code,
+                    is_delete=False
+                ).first()
+                
+                if not container:
+                    return Response({
+                        'success': False,
+                        'message': f'托盘 {container_code} 不存在或已删除'
+                    }, status=status.HTTP_404_NOT_FOUND)
+                
+                # 获取该库位与该托盘的关联
+                active_links = LocationContainerLink.objects.filter(
+                    location=location,
+                    container=container,
+                    is_active=True
+                )
+                
+                if not active_links.exists():
+                    return Response({
+                        'success': False,
+                        'message': f'库位 {location_code} 与托盘 {container_code} 没有绑定关系'
+                    }, status=status.HTTP_400_BAD_REQUEST)
+                
+                unbound_count = active_links.count()
+                
+                with transaction.atomic():
+                    # 标记该关联为非活跃
+                    active_links.update(is_active=False)
+                    
+                    # 检查是否还有其他活跃关联
+                    remaining_links = LocationContainerLink.objects.filter(
+                        location=location,
+                        is_active=True
+                    )
+                    
+                    # 如果没有其他活跃关联,更新库位状态为可用
+                    if not remaining_links.exists():
+                        location.status = 'available'
+                        location.save()
+                    
+                    # 更新库位组状态
+                    allocator = LocationAllocation()
+                    allocator.update_location_group_status(location_code)
+                
+                logger.info(f"成功解除库位 {location_code} 与托盘 {container_code} 的绑定")
+                
                 return Response({
-                    'success': False,
-                    'message': f'库位 {location_code} 没有绑定的托盘'
-                }, status=status.HTTP_400_BAD_REQUEST)
-            
-            # 解除所有关联
-            with transaction.atomic():
-                # 标记所有关联为非活跃
-                active_links.update(is_active=False)
+                    'success': True,
+                    'code': '200',
+                    'message': f'解除托盘 {container_code} 绑定成功',
+                    'data': {
+                        'location_code': location_code,
+                        'container_code': container_code,
+                        'unbound_count': unbound_count,
+                        'remaining_links': remaining_links.count()
+                    }
+                }, status=status.HTTP_200_OK)
+            else:
+                # 解除所有关联
+                active_links = LocationContainerLink.objects.filter(
+                    location=location,
+                    is_active=True
+                )
                 
-                # 更新库位状态为可用
-                location.status = 'available'
-                location.save()
+                if not active_links.exists():
+                    return Response({
+                        'success': False,
+                        'message': f'库位 {location_code} 没有绑定的托盘'
+                    }, status=status.HTTP_400_BAD_REQUEST)
                 
-                # 更新库位组状态
-                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)
+                unbound_count = active_links.count()
+                
+                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': unbound_count,
+                        'new_status': 'available'
+                    }
+                }, status=status.HTTP_200_OK)
             
         except Exception as e:
             logger.error(f"解除托盘绑定失败: {str(e)}", exc_info=True)

+ 0 - 1
templates/dist/spa/css/39.2ac1dad1.css

@@ -1 +0,0 @@
-.q-date__calendar-item--selected[data-v-3867045e]{transition:all 0.3s ease;background-color:#1976d2!important}.q-date__range[data-v-3867045e]{background-color:rgba(25,118,210,0.1)}

+ 1 - 0
templates/dist/spa/css/39.fffe0096.css

@@ -0,0 +1 @@
+.q-date__calendar-item--selected[data-v-67432470]{transition:all 0.3s ease;background-color:#1976d2!important}.q-date__range[data-v-67432470]{background-color:rgba(25,118,210,0.1)}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
templates/dist/spa/css/7.058b37f5.css


+ 0 - 1
templates/dist/spa/css/7.b5042fa0.css

@@ -1 +0,0 @@
-.q-date__calendar-item--selected[data-v-6eb8828c]{transition:all 0.3s ease;background-color:#1976d2!important}.q-date__range[data-v-6eb8828c]{background-color:rgba(25,118,210,0.1)}.custom-title[data-v-6eb8828c]{font-size:0.9rem;font-weight:500}.custom-timeline[data-v-6eb8828c]{--q-timeline-color:#e0e0e0}.custom-node .q-timeline__dot[data-v-6eb8828c]{background:#485573!important;border:2px solid #5c6b8c!important}.custom-node .q-timeline__content[data-v-6eb8828c]{color:#485573}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 1
templates/dist/spa/index.html


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
templates/dist/spa/js/39.4e22ecc5.js


BIN
templates/dist/spa/js/39.4e22ecc5.js.gz


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
templates/dist/spa/js/39.77c36f65.js


BIN
templates/dist/spa/js/39.77c36f65.js.gz


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
templates/dist/spa/js/7.15a488eb.js


BIN
templates/dist/spa/js/7.15a488eb.js.gz


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 0
templates/dist/spa/js/7.1773c9a2.js


BIN
templates/dist/spa/js/7.1773c9a2.js.gz


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 1
templates/dist/spa/js/app.ba0fbeb1.js


BIN
templates/dist/spa/js/app.3082c386.js.gz


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


+ 668 - 16
templates/src/pages/container/containerlist.vue

@@ -143,9 +143,24 @@
               </q-td>
             </template>
             <template v-else-if="props.row.id !== editid">
-              <q-td key="current_location" :props="props">{{
-                props.row.current_location
-              }}</q-td>
+              <q-td key="current_location" :props="props">
+                <div class="row items-center no-wrap">
+                  <div class="col">{{ props.row.current_location }}</div>
+                  <q-btn
+                    v-if="props.row.current_location && props.row.current_location !== 'N/A' && String(props.row.current_location).startsWith('W')"
+                    flat
+                    dense
+                    round
+                    size="sm"
+                    icon="link"
+                    color="primary"
+                    @click="openLocationBindDialog(props.row)"
+                    class="q-ml-xs"
+                  >
+                    <q-tooltip>库位绑定</q-tooltip>
+                  </q-btn>
+                </div>
+              </q-td>
             </template>
 
             <template v-if="props.row.id === editid">
@@ -329,6 +344,219 @@
         </q-card-section>
       </q-card>
     </q-dialog>
+
+    <!-- 库位绑定对话框 -->
+    <q-dialog v-model="locationBindDialog" persistent>
+      <q-card style="min-width: 600px">
+        <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-separator />
+
+        <q-card-section v-if="locationBindData">
+          <q-inner-loading :showing="locationBindData.loading">
+            <q-spinner color="primary" size="50px" />
+          </q-inner-loading>
+
+          <div v-if="!locationBindData.loading">
+            <div class="row q-mb-md">
+              <div class="col-6">
+                <div class="text-caption text-grey">库位编码</div>
+                <div class="text-body1">{{ locationBindData.location_code || '-' }}</div>
+              </div>
+              <div class="col-3">
+                <div class="text-caption text-grey">层/行/列</div>
+                <div class="text-body1">
+                  {{ locationBindData.layer || '-' }}/{{ locationBindData.row || '-' }}/{{ locationBindData.col || '-' }}
+                </div>
+              </div>
+              <div class="col-3">
+                <div class="text-caption text-grey">库位状态</div>
+                <div class="text-body1">{{ locationBindData.status || '-' }}</div>
+              </div>
+            </div>
+
+            <q-separator class="q-my-md" />
+
+            <div class="q-mb-md">
+              <div class="text-subtitle2 q-mb-sm">已绑定的托盘</div>
+              <div v-if="locationBindData.bound_containers && locationBindData.bound_containers.length > 0">
+                <q-list bordered>
+                  <q-item
+                    v-for="(container, index) in locationBindData.bound_containers"
+                    :key="index"
+                  >
+                    <q-item-section>
+                      <q-item-label>
+                        托盘码: {{ container.container_code }}
+                        <q-badge
+                          v-if="isSameContainerCode(container.container_code, locationBindData.current_container_code)"
+                          color="green"
+                          class="q-ml-sm"
+                        >
+                          当前托盘
+                        </q-badge>
+                        <q-badge
+                          v-else-if="locationBindData.current_container_code && !isSameContainerCode(container.container_code, locationBindData.current_container_code)"
+                          color="orange"
+                          class="q-ml-sm"
+                        >
+                          不一致
+                        </q-badge>
+                      </q-item-label>
+                      <q-item-label caption>绑定时间: {{ formatDateTime(container.put_time) }}</q-item-label>
+                    </q-item-section>
+                    <q-item-section side>
+                      <q-btn
+                        flat
+                        dense
+                        round
+                        icon="link_off"
+                        color="negative"
+                        @click="handleUnbindContainer(container.container_code)"
+                        :loading="container.unbinding"
+                        :disable="bindingContainer"
+                      >
+                        <q-tooltip>解除绑定</q-tooltip>
+                      </q-btn>
+                    </q-item-section>
+                  </q-item>
+                </q-list>
+              </div>
+              <div v-else class="text-grey text-caption">该库位未绑定托盘</div>
+            </div>
+
+            <div class="q-mb-md">
+              <div class="text-subtitle2 q-mb-sm">当前托盘信息(从托盘列表)</div>
+              <div class="text-body2">
+                托盘码: <strong>{{ locationBindData.current_container_code || '-' }}</strong>
+              </div>
+            </div>
+
+            <q-separator class="q-my-md" />
+
+            <div v-if="locationBindData.needs_bind" class="q-mb-md">
+              <q-banner
+                rounded
+                dense
+                class="bg-orange-1 text-orange-9"
+              >
+                <template v-slot:avatar>
+                  <q-icon name="warning" color="orange" />
+                </template>
+                <div class="text-weight-medium q-mb-xs">绑定关系不一致</div>
+                <div class="text-body2">
+                  {{ locationBindData.bind_reason || '库位绑定关系与托盘列表不一致,请确认是否需要重新绑定。' }}
+                </div>
+              </q-banner>
+            </div>
+
+            <div v-else class="q-mb-md">
+              <q-banner
+                rounded
+                dense
+                class="bg-green-1 text-green-9"
+              >
+                <template v-slot:avatar>
+                  <q-icon name="check_circle" color="green" />
+                </template>
+                <div class="text-body2">绑定关系一致</div>
+              </q-banner>
+            </div>
+
+            <!-- 绑定操作按钮区域 -->
+            <div class="q-mt-md">
+              <div class="row q-gutter-sm">
+                <!-- 始终显示绑定按钮(即使没有绑定关系) -->
+                <div
+                  v-if="locationBindData.current_container_code"
+                  class="col"
+                >
+                  <q-btn
+                    color="primary"
+                    :label="locationBindData.bound_containers && locationBindData.bound_containers.length > 0 && locationBindData.bound_containers.some(c => isSameContainerCode(c.container_code, locationBindData.current_container_code)) ? '重新绑定当前托盘' : '绑定当前托盘'"
+                    icon="link"
+                    @click="handleBindContainer"
+                    :loading="bindingContainer"
+                    class="full-width"
+                  />
+                </div>
+                <!-- 如果有已绑定的托盘,显示解除所有绑定按钮 -->
+                <div
+                  v-if="locationBindData.bound_containers && locationBindData.bound_containers.length > 0"
+                  class="col"
+                >
+                  <q-btn
+                    color="negative"
+                    label="解除所有绑定"
+                    icon="link_off"
+                    @click="handleUnbindAllContainers"
+                    :loading="unbindingAll"
+                    class="full-width"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right">
+          <q-btn flat label="关闭" v-close-popup />
+        </q-card-actions>
+      </q-card>
+    </q-dialog>
+
+    <!-- 新增托盘对话框 -->
+    <q-dialog v-model="addContainerDialog" persistent>
+      <q-card style="min-width: 400px">
+        <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-separator />
+
+        <q-card-section>
+          <q-input
+            v-model="newContainerCode"
+            label="托盘编码(5位)"
+            outlined
+            :rules="[
+              val => (val && val.length === 5) || '托盘编码必须为5位',
+              val => /^[A-Za-z0-9]+$/.test(val) || '托盘编码只能包含字母和数字'
+            ]"
+            maxlength="5"
+            @input="checkContainerCodeExists"
+            :loading="checkingContainerCode"
+          >
+            <template v-slot:hint>
+              <div v-if="containerCodeCheckResult === 'exists'" class="text-negative">
+                该托盘编码已存在
+              </div>
+              <div v-else-if="containerCodeCheckResult === 'available'" class="text-positive">
+                该托盘编码可以使用
+              </div>
+            </template>
+          </q-input>
+        </q-card-section>
+
+        <q-card-actions align="right">
+          <q-btn flat label="取消" v-close-popup @click="resetAddDialog" />
+          <q-btn
+            flat
+            label="确定"
+            color="primary"
+            @click="confirmAddContainer"
+            :disable="!newContainerCode || newContainerCode.length !== 5 || containerCodeCheckResult === 'exists' || checkingContainerCode"
+            :loading="addingContainer"
+          />
+        </q-card-actions>
+      </q-card>
+    </q-dialog>
   </div>
 </template>
 
@@ -461,12 +689,21 @@ export default {
       activeSearchField: '',
       activeSearchLabel: '',
       userComponentPermissions: [], // 用户权限
-      login_mode: LocalStorage.getItem('login_mode') // 登录模式
+      login_mode: LocalStorage.getItem('login_mode'), // 登录模式
+      locationBindDialog: false,
+      locationBindData: null,
+      bindingContainer: false,
+      // 新增托盘对话框相关
+      addContainerDialog: false,
+      newContainerCode: '',
+      containerCodeCheckResult: '', // 'exists' | 'available' | ''
+      checkingContainerCode: false,
+      addingContainer: false
     }
   },
 
   methods: {
-     // 检查用户是否有指定页面的组件访问权限
+    // 检查用户是否有指定页面的组件访问权限
     loadUserPermissions () {
       postauth('staff/role-comPermissions/' + this.login_mode + '/', {
         page: '/container/containerlist'
@@ -695,22 +932,100 @@ export default {
       }
     },
     add () {
-      postauth('/container/list/', {})
-        .then((res) => {
-          this.$q.notify({
-            message: '添加成功',
-            icon: 'done',
-            color: 'positive'
-          })
-          this.reFresh()
+      this.resetAddDialog()
+      this.addContainerDialog = true
+    },
+    // 重置新增对话框
+    resetAddDialog () {
+      this.newContainerCode = ''
+      this.containerCodeCheckResult = ''
+      this.checkingContainerCode = false
+    },
+    // 校验托盘码是否存在
+    async checkContainerCodeExists () {
+      // 清空之前的校验结果
+      this.containerCodeCheckResult = ''
+
+      // 如果托盘码不是5位,不进行校验
+      if (!this.newContainerCode || this.newContainerCode.length !== 5) {
+        return
+      }
+
+      this.checkingContainerCode = true
+      try {
+        // 查询是否已存在该托盘码
+        const url = `${this.pathname}?container_code=${encodeURIComponent(this.newContainerCode)}`
+        const response = await getauth(url)
+
+        if (response && response.results && response.results.length > 0) {
+          this.containerCodeCheckResult = 'exists'
+        } else {
+          this.containerCodeCheckResult = 'available'
+        }
+      } catch (error) {
+        console.error('校验托盘码失败:', error)
+        // 校验失败时不清除结果,让用户知道需要重试
+      } finally {
+        this.checkingContainerCode = false
+      }
+    },
+    // 确认新增托盘
+    async confirmAddContainer () {
+      // 最终校验
+      if (!this.newContainerCode || this.newContainerCode.length !== 5) {
+        this.$q.notify({
+          message: '托盘编码必须为5位',
+          icon: 'close',
+          color: 'negative'
         })
-        .catch((err) => {
+        return
+      }
+
+      // 如果校验结果不明确,再次校验
+      if (this.containerCodeCheckResult !== 'available') {
+        await this.checkContainerCodeExists()
+        if (this.containerCodeCheckResult === 'exists') {
           this.$q.notify({
-            message: err.detail,
+            message: '该托盘编码已存在,请使用其他编码',
             icon: 'close',
             color: 'negative'
           })
+          return
+        }
+        if (this.containerCodeCheckResult !== 'available') {
+          this.$q.notify({
+            message: '请稍候,正在校验托盘编码...',
+            icon: 'info',
+            color: 'info'
+          })
+          return
+        }
+      }
+
+      this.addingContainer = true
+      try {
+        await postauth('/container/list/', {
+          container_code: this.newContainerCode
+        })
+
+        this.$q.notify({
+          message: '添加成功',
+          icon: 'done',
+          color: 'positive'
+        })
+        this.addContainerDialog = false
+        this.resetAddDialog()
+        this.reFresh()
+      } catch (err) {
+        const errorMessage = err.detail || err.message || '添加失败'
+        this.$q.notify({
+          message: errorMessage,
+          icon: 'close',
+          color: 'negative'
         })
+      } finally {
+        this.addingContainer = false
+      }
     },
     check_container () {
       var _this = this
@@ -921,11 +1236,348 @@ export default {
             color: 'negative'
           })
         })
+    },
+    // 打开库位绑定对话框
+    async openLocationBindDialog (row) {
+      if (!row || !row.current_location || row.current_location === 'N/A') {
+        this.$q.notify({
+          type: 'negative',
+          message: '该托盘没有当前位置信息'
+        })
+        return
+      }
+
+      const locationCode = row.current_location
+      this.locationBindDialog = true
+      this.locationBindData = {
+        location_code: locationCode,
+        current_container_code: row.container_code,
+        loading: true
+      }
+
+      try {
+        // 查询库位信息
+        // await this.queryLocationInfo(locationCode)
+        // 查询库位绑定的托盘
+        await this.queryLocationContainers(locationCode)
+        // 检查是否需要绑定
+        this.checkBindingStatus()
+      } catch (error) {
+        console.error('查询库位信息失败:', error)
+        this.$q.notify({
+          type: 'negative',
+          message: '查询库位信息失败: ' + (error?.message || '未知错误')
+        })
+      } finally {
+        this.locationBindData.loading = false
+      }
+    },
+    // 查询库位信息
+    async queryLocationInfo (locationCode) {
+      try {
+        // 从库位列表查询库位信息
+        const url = `bin/?location_code=${encodeURIComponent(locationCode)}`
+        const response = await getauth(url)
+
+        if (response && response.results && response.results.length > 0) {
+          const location = response.results[0]
+
+          this.locationBindData = {
+            ...this.locationBindData,
+            location_code: location.location_code,
+            row: location.row,
+            col: location.col,
+            layer: location.layer,
+            status: location.status,
+            location_type: location.location_type
+          }
+        } else {
+          // 如果查询不到,使用基本信息
+          this.locationBindData = {
+            ...this.locationBindData,
+            location_code: locationCode
+          }
+        }
+      } catch (error) {
+        console.error('查询库位信息失败:', error)
+        // 即使查询失败,也保留基本信息
+        this.locationBindData = {
+          ...this.locationBindData,
+          location_code: locationCode
+        }
+      }
+    },
+    // 查询库位绑定的托盘(使用新的 link API)
+    async queryLocationContainers (locationCode) {
+      try {
+        // 使用新的 link API 查询库位关联的托盘
+        const url = `bin/link/?location_code=${encodeURIComponent(locationCode)}&is_active=true`
+        const response = await getauth(url)
+
+        const boundContainers = []
+        if (response && response.results && Array.isArray(response.results)) {
+          response.results.forEach(link => {
+            if (link.is_active && link.container_code) {
+              boundContainers.push({
+                container_code: link.container_code,
+                put_time: link.put_time || link.create_time
+              })
+            }
+          })
+        }
+
+        this.locationBindData.bound_containers = boundContainers
+      } catch (error) {
+        console.error('查询库位关联托盘失败:', error)
+        this.locationBindData.bound_containers = []
+      }
+    },
+    // 检查绑定状态
+    checkBindingStatus () {
+      const boundContainers = this.locationBindData.bound_containers || []
+      const currentContainerCode = this.locationBindData.current_container_code
+      const locationStatus = this.locationBindData.status
+
+      // 检查是否需要绑定
+      let needsBind = false
+      let bindReason = ''
+
+      // 如果没有绑定任何托盘,且当前托盘存在,需要绑定
+      if (boundContainers.length === 0 && currentContainerCode) {
+        needsBind = true
+        if (locationStatus === 'occupied' || locationStatus === 'reserved') {
+          bindReason = '库位状态为占用,但未绑定托盘'
+        } else {
+          bindReason = '库位未绑定托盘,需要绑定当前托盘'
+        }
+      } else if (locationStatus === 'occupied' || locationStatus === 'reserved') {
+        // 库位状态为占用,应该绑定托盘
+        if (boundContainers.length === 0) {
+          needsBind = true
+          bindReason = '库位状态为占用,但未绑定托盘'
+        } else if (currentContainerCode && !boundContainers.some(c => this.isSameContainerCode(c.container_code, currentContainerCode))) {
+          needsBind = true
+          bindReason = '库位绑定的托盘与当前托盘不一致'
+        }
+      } else if (locationStatus === 'available' && boundContainers.length > 0) {
+        // 库位状态为可用,但绑定了托盘
+        if (currentContainerCode && boundContainers.some(c => this.isSameContainerCode(c.container_code, currentContainerCode))) {
+          needsBind = true
+          bindReason = '库位状态为可用,但绑定了托盘'
+        }
+      }
+
+      this.locationBindData.needs_bind = needsBind
+      this.locationBindData.bind_reason = bindReason
+    },
+    // 绑定托盘
+    async handleBindContainer () {
+      if (!this.locationBindData || !this.locationBindData.location_code) {
+        return
+      }
+
+      const locationCode = this.locationBindData.location_code
+      const containerCode = this.locationBindData.current_container_code
+
+      if (!containerCode) {
+        this.$q.notify({
+          type: 'negative',
+          message: '无法获取托盘编码'
+        })
+        return
+      }
+
+      this.bindingContainer = true
+      try {
+        const url = '/location_statistics/bind-container/'
+        const payload = {
+          location_code: locationCode,
+          container_code: containerCode
+        }
+
+        const response = await postauth(url, payload)
+
+        if (response && (response.code === '200' || response.success)) {
+          this.$q.notify({
+            type: 'positive',
+            message: '绑定成功',
+            icon: 'check'
+          })
+          // 重新查询绑定关系和库位信息(确保状态已更新,参考任务结束后的逻辑)
+          await Promise.all([
+            this.queryLocationContainers(locationCode),
+            this.queryLocationInfo(locationCode)
+          ])
+          this.checkBindingStatus()
+          // 刷新列表
+          this.getSearchList()
+        } else {
+          this.$q.notify({
+            type: 'negative',
+            message: response?.msg || response?.message || '绑定失败'
+          })
+        }
+      } catch (error) {
+        console.error('绑定失败:', error)
+        this.$q.notify({
+          type: 'negative',
+          message: '绑定失败: ' + (error?.message || error?.detail || '未知错误')
+        })
+      } finally {
+        this.bindingContainer = false
+      }
+    },
+    // 解除单个托盘绑定
+    async handleUnbindContainer (containerCode) {
+      if (!this.locationBindData || !this.locationBindData.location_code) {
+        return
+      }
+
+      const locationCode = this.locationBindData.location_code
+
+      // 确认对话框
+      this.$q
+        .dialog({
+          title: '确认解除绑定',
+          message: `确定要解除库位 ${locationCode} 与托盘 ${containerCode} 的绑定关系吗?`,
+          cancel: true,
+          persistent: true
+        })
+        .onOk(async () => {
+          // 找到对应的容器对象,设置 loading 状态
+          const container = this.locationBindData.bound_containers.find(
+            c => this.isSameContainerCode(c.container_code, containerCode)
+          )
+          if (container) {
+            this.$set(container, 'unbinding', true)
+          }
+
+          try {
+            const url = '/location_statistics/unbind-container/'
+            const payload = {
+              location_code: locationCode,
+              container_code: containerCode
+            }
+
+            const response = await postauth(url, payload)
+
+            if (response && (response.code === '200' || response.success)) {
+              this.$q.notify({
+                type: 'positive',
+                message: '解除绑定成功',
+                icon: 'check'
+              })
+              // 重新查询绑定关系和库位信息(确保状态已更新)
+              await Promise.all([
+                this.queryLocationContainers(locationCode),
+                this.queryLocationInfo(locationCode)
+              ])
+              this.checkBindingStatus()
+              // 刷新列表
+              this.getSearchList()
+            } else {
+              this.$q.notify({
+                type: 'negative',
+                message: response?.msg || response?.message || '解除绑定失败'
+              })
+            }
+          } catch (error) {
+            console.error('解除绑定失败:', error)
+            this.$q.notify({
+              type: 'negative',
+              message: '解除绑定失败: ' + (error?.message || error?.detail || '未知错误')
+            })
+          } finally {
+            if (container) {
+              this.$set(container, 'unbinding', false)
+            }
+          }
+        })
+    },
+    // 解除所有绑定
+    async handleUnbindAllContainers () {
+      if (!this.locationBindData || !this.locationBindData.location_code) {
+        return
+      }
+
+      const locationCode = this.locationBindData.location_code
+      const boundContainers = this.locationBindData.bound_containers || []
+
+      if (boundContainers.length === 0) {
+        this.$q.notify({
+          type: 'info',
+          message: '该库位没有绑定的托盘'
+        })
+        return
+      }
+
+      // 确认对话框
+      this.$q
+        .dialog({
+          title: '确认解除所有绑定',
+          message: `确定要解除库位 ${locationCode} 的所有托盘绑定关系吗?共 ${boundContainers.length} 个托盘。`,
+          cancel: true,
+          persistent: true
+        })
+        .onOk(async () => {
+          this.unbindingAll = true
+
+          try {
+            const url = '/location_statistics/unbind-container/'
+            const payload = {
+              location_code: locationCode
+            }
+
+            const response = await postauth(url, payload)
+
+            if (response && (response.code === '200' || response.success)) {
+              this.$q.notify({
+                type: 'positive',
+                message: '解除所有绑定成功',
+                icon: 'check'
+              })
+              // 重新查询绑定关系和库位信息(确保状态已更新)
+              await Promise.all([
+                this.queryLocationContainers(locationCode),
+                this.queryLocationInfo(locationCode)
+              ])
+              this.checkBindingStatus()
+              // 刷新列表
+              this.getSearchList()
+            } else {
+              this.$q.notify({
+                type: 'negative',
+                message: response?.msg || response?.message || '解除绑定失败'
+              })
+            }
+          } catch (error) {
+            console.error('解除绑定失败:', error)
+            this.$q.notify({
+              type: 'negative',
+              message: '解除绑定失败: ' + (error?.message || error?.detail || '未知错误')
+            })
+          } finally {
+            this.unbindingAll = false
+          }
+        })
+    },
+    // 格式化时间
+    formatDateTime (dateStr) {
+      if (!dateStr) return '-'
+      if (typeof dateStr === 'string' && dateStr.includes('T')) {
+        return dateStr.replace('T', ' ').substring(0, 19)
+      }
+      return dateStr
+    },
+    // 比较容器码(处理类型不一致问题)
+    isSameContainerCode (code1, code2) {
+      if (!code1 || !code2) return false
+      return String(code1).trim() === String(code2).trim()
     }
   },
   created () {
     var _this = this
-    _this.loadUserPermissions();
+    _this.loadUserPermissions()
     if (LocalStorage.has('openid')) {
       _this.openid = LocalStorage.getItem('openid')
     } else {

+ 129 - 3
templates/src/pages/count/batch.vue

@@ -186,10 +186,30 @@
               :key="col.name"
               :props="props"
             >
-              <span v-if="col.name === 'check_status'">
+              <!-- 管理批次列:显示批次号+打印按钮 -->
+              <template v-if="col.name === 'bound_number'">
+                <div class="row items-center no-wrap">
+                  <div class="col">{{ props.row.bound_number }}</div>
+                  <q-btn
+                    icon="print"
+                    flat
+                    dense
+                    round
+                    size="sm"
+                    v-print="getPrintConfig()"
+                    @click="setCurrentBatch(props.row)"
+                    class="q-ml-xs"
+                  >
+                    <q-tooltip>打印条码</q-tooltip>
+                  </q-btn>
+                </div>
+              </template>
+              <!-- 质检状态列:显示状态文本 -->
+              <span v-else-if="col.name === 'check_status'">
                 {{ checkStatusToText(props.row[col.field]) }}
               </span>
-              <span v-else-if="col.name !== 'check_status'">
+              <!-- 其他列:显示字段值 -->
+              <span v-else>
                 {{ col.field ? props.row[col.field] : props.row[col.name] }}
               </span>
             </q-td>
@@ -517,6 +537,21 @@
         </q-card-actions>
       </q-card>
     </q-dialog>
+    <div id="printBarcode" class="print-area">
+      <div class="q-pa-md text-center" style="flex: none">
+        <div class="row no-wrap">
+          <div class="col text-left">
+            <p style="font-weight: 500">{{ currentgoods.goods_desc || '' }}</p>
+          </div>
+          <div class="col text-right">
+            <p>
+              数量: {{ currentgoods.goods_qty || '' }}{{ currentgoods.goods_unit || '' }}
+            </p>
+          </div>
+        </div>
+        <svg ref="barcodeElement" style="width: 100%; height: auto"></svg>
+      </div>
+    </div>
   </div>
 </template>
 <router-view />
@@ -525,6 +560,7 @@
 import { getauth, postauth, putauth, deleteauth, getfile } from 'boot/axios_request'
 import { date, exportFile, LocalStorage } from 'quasar'
 import containercard from 'components/containercard.vue'
+import JsBarcode from 'jsbarcode'
 
 
 export default {
@@ -690,7 +726,10 @@ export default {
       userComponentPermissions: [], // 用户权限
       login_mode: LocalStorage.getItem('login_mode'), // 登录模式
       selectedBatchId: null,
-      onlyDownloadInStock: true // 默认只下载在库数量大于0的批次
+      onlyDownloadInStock: true, // 默认只下载在库数量大于0的批次
+      // 打印相关
+      currentBarcode: '',
+      currentgoods: {}
     }
   },
   computed: {
@@ -956,6 +995,37 @@ export default {
       _this.select_container_code = container.container_code
       _this.$refs.containercard.handleclick()
     },
+    // 打印相关方法
+    setCurrentBatch (row) {
+      this.currentBarcode = row.bound_number || ''
+      this.currentgoods = {
+        goods_desc: row.goods_desc || '',
+        goods_qty: row.goods_qty || '',
+        goods_unit: row.goods_unit || ''
+      }
+    },
+    getPrintConfig () {
+      this.generateBarcode()
+      return {
+        id: 'printBarcode'
+      }
+    },
+    generateBarcode () {
+      if (!this.$refs.barcodeElement) return
+      this.$refs.barcodeElement.innerHTML = ''
+
+      try {
+        JsBarcode(this.$refs.barcodeElement, this.currentBarcode, {
+          format: 'CODE128',
+          displayValue: true,
+          fontSize: 16,
+          height: 60,
+          margin: 10
+        })
+      } catch (error) {
+        console.error('条码生成失败:', error)
+      }
+    },
 
     class_to_name (class_id) {
       const class_map = {
@@ -1326,4 +1396,60 @@ export default {
 .custom-node .q-timeline__content {
   color: #485573; /* 文字颜色 */
 }
+
+/* 修改打印样式 */
+#printBarcode {
+  width: 80mm; /* 标准标签纸宽度 */
+  max-width: 50mm;
+  padding: 2mm;
+  box-sizing: border-box;
+}
+
+/* 打印时强制缩放 */
+@media print {
+  body {
+    margin: 0 !important;
+    visibility: hidden;
+  }
+
+  #printBarcode,
+  #printBarcode * {
+    visibility: visible;
+    width: 100% !important;
+    max-width: 100% !important;
+  }
+
+  /* 强制单页布局 */
+  .print-area {
+    page-break-inside: avoid;
+    break-inside: avoid;
+    display: flex;
+    flex-direction: column;
+    gap: 2mm;
+  }
+
+  /* 条码缩放 */
+  svg {
+    transform: scale(0.9);
+    transform-origin: center top;
+    max-height: 30mm !important;
+  }
+
+  /* 文本适配 */
+  p {
+    font-size: 9pt !important;
+    margin: 0;
+    line-height: 1.2;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  /* 网格布局优化 */
+  .row {
+    display: grid !important;
+    grid-template-columns: 1fr 1fr;
+    gap: 1mm;
+  }
+}
 </style>