Browse Source

任务优化

flower_bs 3 weeks ago
parent
commit
c0a234efa6
100 changed files with 1356 additions and 74 deletions
  1. 1 1
      container/container_operate.py
  2. 1 1
      container/filter.py
  3. 22 0
      container/migrations/0039_containerwcsmodel_location_group_fields.py
  4. 20 0
      container/migrations/0040_remove_access_priority_fields.py
  5. 5 3
      container/models.py
  6. 37 1
      container/serializers.py
  7. 252 50
      container/views.py
  8. 0 0
      cyclecount/__init__.py
  9. 60 0
      cyclecount/admin.py
  10. 8 0
      cyclecount/apps.py
  11. 95 0
      cyclecount/migrations/0001_initial.py
  12. 20 0
      cyclecount/migrations/0002_remove_counttask_warehouse_fields.py
  13. 0 0
      cyclecount/migrations/__init__.py
  14. 254 0
      cyclecount/models.py
  15. 123 0
      cyclecount/serializers.py
  16. 78 0
      cyclecount/urls.py
  17. 357 0
      cyclecount/views.py
  18. 1 0
      greaterwms/settings.py
  19. 1 0
      greaterwms/urls.py
  20. 1 0
      templates/dist/spa/css/12.50853e25.css
  21. 0 0
      templates/dist/spa/css/13.c5bafa54.css
  22. 0 0
      templates/dist/spa/css/14.b352291e.css
  23. 0 0
      templates/dist/spa/css/15.84ffb38a.css
  24. 0 0
      templates/dist/spa/css/16.285d986e.css
  25. 0 0
      templates/dist/spa/css/17.80435c82.css
  26. 0 0
      templates/dist/spa/css/18.074df1c7.css
  27. 0 0
      templates/dist/spa/css/19.f57b1220.css
  28. 0 0
      templates/dist/spa/css/20.9e7bbbba.css
  29. 0 0
      templates/dist/spa/css/21.296f042c.css
  30. 0 0
      templates/dist/spa/css/22.8fe0c02a.css
  31. 0 0
      templates/dist/spa/css/23.abdf7366.css
  32. 0 0
      templates/dist/spa/css/24.83beb898.css
  33. 0 0
      templates/dist/spa/css/25.601677c3.css
  34. 0 0
      templates/dist/spa/css/26.6dea747f.css
  35. 0 0
      templates/dist/spa/css/27.f721cf95.css
  36. 0 0
      templates/dist/spa/css/28.ed8e81e9.css
  37. 0 0
      templates/dist/spa/css/29.eed22a1c.css
  38. 0 0
      templates/dist/spa/css/30.2f5a0931.css
  39. 0 0
      templates/dist/spa/css/31.e6923623.css
  40. 0 0
      templates/dist/spa/css/32.01a9029f.css
  41. 0 0
      templates/dist/spa/css/33.9b0c5133.css
  42. 0 0
      templates/dist/spa/css/34.103b121d.css
  43. 0 1
      templates/dist/spa/css/34.6a5a5158.css
  44. 0 1
      templates/dist/spa/css/35.56a2e799.css
  45. 1 0
      templates/dist/spa/css/35.da66b954.css
  46. 1 0
      templates/dist/spa/css/36.d96cdb0f.css
  47. 0 0
      templates/dist/spa/css/37.064414c5.css
  48. 0 0
      templates/dist/spa/css/38.44ddcebd.css
  49. 0 0
      templates/dist/spa/css/39.2ac1dad1.css
  50. 0 0
      templates/dist/spa/css/40.12670fd1.css
  51. 0 0
      templates/dist/spa/css/41.9478c981.css
  52. 0 0
      templates/dist/spa/css/42.c4652654.css
  53. 0 0
      templates/dist/spa/css/43.7a23b7fb.css
  54. 0 0
      templates/dist/spa/css/44.07732723.css
  55. 0 0
      templates/dist/spa/css/45.2594d0b9.css
  56. 0 0
      templates/dist/spa/css/46.0faa4aeb.css
  57. 1 1
      templates/dist/spa/index.html
  58. 1 0
      templates/dist/spa/js/100.90c42c1f.js
  59. 1 0
      templates/dist/spa/js/12.0e19bf74.js
  60. BIN
      templates/dist/spa/js/12.0e19bf74.js.gz
  61. 1 1
      templates/dist/spa/js/12.19cc6ade.js
  62. BIN
      templates/dist/spa/js/12.19cc6ade.js.gz
  63. BIN
      templates/dist/spa/js/14.0b3059c5.js.gz
  64. 1 1
      templates/dist/spa/js/13.eb7c38b1.js
  65. BIN
      templates/dist/spa/js/13.eb7c38b1.js.gz
  66. 1 1
      templates/dist/spa/js/14.0b3059c5.js
  67. BIN
      templates/dist/spa/js/15.83475395.js.gz
  68. BIN
      templates/dist/spa/js/15.b7dd6c80.js.gz
  69. 1 1
      templates/dist/spa/js/15.b7dd6c80.js
  70. BIN
      templates/dist/spa/js/16.9ff541bd.js.gz
  71. 1 1
      templates/dist/spa/js/16.2ed466dc.js
  72. BIN
      templates/dist/spa/js/16.2ed466dc.js.gz
  73. BIN
      templates/dist/spa/js/18.18213a6e.js.gz
  74. 1 1
      templates/dist/spa/js/17.9115b827.js
  75. BIN
      templates/dist/spa/js/17.9115b827.js.gz
  76. 1 1
      templates/dist/spa/js/18.18213a6e.js
  77. BIN
      templates/dist/spa/js/19.8a3a5eae.js.gz
  78. BIN
      templates/dist/spa/js/19.b1cca1b7.js.gz
  79. BIN
      templates/dist/spa/js/20.4730a8aa.js.gz
  80. 1 1
      templates/dist/spa/js/19.b1cca1b7.js
  81. BIN
      templates/dist/spa/js/20.9b815f26.js.gz
  82. 1 1
      templates/dist/spa/js/20.4730a8aa.js
  83. BIN
      templates/dist/spa/js/21.00122f16.js.gz
  84. BIN
      templates/dist/spa/js/21.6eee568e.js.gz
  85. 1 1
      templates/dist/spa/js/21.6eee568e.js
  86. BIN
      templates/dist/spa/js/22.b07fa6f1.js.gz
  87. BIN
      templates/dist/spa/js/22.ea906fdf.js.gz
  88. 1 1
      templates/dist/spa/js/22.ea906fdf.js
  89. BIN
      templates/dist/spa/js/23.6b9b1c0b.js.gz
  90. BIN
      templates/dist/spa/js/24.31e1a220.js.gz
  91. 1 1
      templates/dist/spa/js/23.e455edd8.js
  92. BIN
      templates/dist/spa/js/23.e455edd8.js.gz
  93. BIN
      templates/dist/spa/js/25.91acd9c1.js.gz
  94. 1 1
      templates/dist/spa/js/24.31e1a220.js
  95. BIN
      templates/dist/spa/js/25.d3a90663.js.gz
  96. 1 1
      templates/dist/spa/js/25.91acd9c1.js
  97. BIN
      templates/dist/spa/js/26.04fed0ec.js.gz
  98. 1 1
      templates/dist/spa/js/26.c2f59029.js
  99. BIN
      templates/dist/spa/js/26.c2f59029.js.gz
  100. 0 0
      templates/dist/spa/js/27.c6cfb996.js

+ 1 - 1
container/container_operate.py

@@ -2,7 +2,7 @@ from django.db import transaction
 from rest_framework.response import Response
 from rest_framework.views import APIView
 from django.utils import timezone
-from .models import ContainerListModel,ContainerDetailModel,ContainerOperationModel,TaskModel,ContainerWCSModel
+from .models import ContainerListModel,ContainerDetailModel,ContainerOperationModel
 from bound.models import BoundBatchModel,BoundDetailModel,BoundListModel
 from .serializers import *
 

+ 1 - 1
container/filter.py

@@ -126,6 +126,7 @@ class WCSTaskFilter(FilterSet):
             "status": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
             "create_time": ['exact', 'gt', 'gte', 'lt', 'lte', 'range'],
             "is_delete": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
+            "location_group_id": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
         }
 
 class TaskFilter(FilterSet):
@@ -151,7 +152,6 @@ class WCSTaskLogFilter(FilterSet):
             "current_location": ['exact', 'icontains'],
             "target_location": ['exact', 'icontains'],
             "location_group_id": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
-            "access_priority": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
             "left_priority": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
             "right_priority": ['exact', 'gt', 'gte', 'lt', 'lte', 'isnull', 'in', 'range'],
             "task_type": ['exact', 'icontains'],

+ 22 - 0
container/migrations/0039_containerwcsmodel_location_group_fields.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('container', '0038_wcstasklogmodel_floor_wcstasklogmodel_is_completed_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='containerwcsmodel',
+            name='location_group_id',
+            field=models.IntegerField(blank=True, null=True, verbose_name='库位组ID'),
+        ),
+        migrations.AddField(
+            model_name='containerwcsmodel',
+            name='access_priority',
+            field=models.IntegerField(blank=True, null=True, verbose_name='靠里程度优先级'),
+        ),
+    ]
+

+ 20 - 0
container/migrations/0040_remove_access_priority_fields.py

@@ -0,0 +1,20 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('container', '0039_containerwcsmodel_location_group_fields'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='containerwcsmodel',
+            name='access_priority',
+        ),
+        migrations.RemoveField(
+            model_name='wcstasklogmodel',
+            name='access_priority',
+        ),
+    ]
+

+ 5 - 3
container/models.py

@@ -758,7 +758,7 @@ class ContainerWCSModel(models.Model):
     message = models.TextField(verbose_name='消息')
     working = models.IntegerField(default = 1,verbose_name='工作状态')
     status = models.IntegerField(choices=TASK_STATUS, default=100, verbose_name='状态')
-
+    location_group_id = models.IntegerField(null=True, blank=True, verbose_name='库位组ID')
     create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
     is_delete = models.BooleanField(default=False, verbose_name='是否删除')
 
@@ -779,7 +779,10 @@ class ContainerWCSModel(models.Model):
             'taskNumber': self.tasknumber-20000000000,
             'message': self.message,
             'container': self.container,
-            'status': self.status
+            'status': self.status,
+            'location_group_id': self.location_group_id,
+            'batch_number': self.batch_number,
+            'bound_list_id': self.bound_list_id,
         }
     def __str__(self):
         return f"{self.taskid} - {self.get_status_display()}"
@@ -857,7 +860,6 @@ class WCSTaskLogModel(models.Model):
     current_location = models.CharField(max_length=100, verbose_name='起始位置')
     target_location = models.CharField(max_length=100, verbose_name='目标位置')
     location_group_id = models.IntegerField(null=True, blank=True, verbose_name='库位组ID')
-    access_priority = models.IntegerField(null=True, blank=True, verbose_name='靠里程度优先级')
     left_priority = models.IntegerField(null=True, blank=True, verbose_name='左侧优先级')
     right_priority = models.IntegerField(null=True, blank=True, verbose_name='右侧优先级')
     task_type = models.CharField(max_length=50, null=True, blank=True, verbose_name='任务类型')

+ 37 - 1
container/serializers.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 from .models import ContainerListModel,ContainerDetailModel,ContainerOperationModel,TaskModel,ContainerWCSModel,out_batch_detail,ContainerDetailLogModel,batchLogModel,DispatchConfig,WCSTaskLogModel
 from bound.models import BoundBatchModel,BoundDetailModel
+from bin.models import LocationGroupModel
 
 from utils import datasolve
 from decimal import Decimal
@@ -28,12 +29,47 @@ class batchLogModelSerializer(serializers.ModelSerializer):
     
 class WCSTaskGetSerializer(serializers.ModelSerializer):
     batch_code = serializers.CharField(source='batch.bound_number', read_only=True)
+    outbound_code = serializers.SerializerMethodField()
+    related_batch_number = serializers.SerializerMethodField()
+    location_group_code = serializers.SerializerMethodField()
     class Meta:
         # 指定模型和排除字段
         model = ContainerWCSModel
         fields= '__all__'
         read_only_fields = ['id']
 
+    def get_field_names(self, declared_fields, info):
+        base_fields = super().get_field_names(declared_fields, info)
+        extra = ['batch_code', 'outbound_code', 'related_batch_number', 'location_group_code']
+        return list(dict.fromkeys(list(base_fields) + extra))
+
+    def get_outbound_code(self, obj):
+        if getattr(obj, 'bound_list_id', None) and obj.bound_list:
+            return obj.bound_list.bound_code
+        if getattr(obj, 'batch_out_id', None) and obj.batch_out and obj.batch_out.bound_list:
+            return obj.batch_out.bound_list.bound_code
+        return None
+
+    def get_related_batch_number(self, obj):
+        if getattr(obj, 'batch_out_id', None) and obj.batch_out:
+            if obj.batch_out.batch_number:
+                return obj.batch_out.batch_number.bound_number
+            return obj.batch_out.out_number
+        if getattr(obj, 'batch_id', None) and obj.batch:
+            return obj.batch.bound_number
+        return obj.batch_number
+
+    def get_location_group_code(self, obj):
+        if not getattr(obj, 'location_group_id', None):
+            return None
+        cache = getattr(self, '_location_group_cache', {})
+        if obj.location_group_id not in cache:
+            cache[obj.location_group_id] = LocationGroupModel.objects.filter(
+                id=obj.location_group_id
+            ).values_list('group_code', flat=True).first()
+            self._location_group_cache = cache
+        return cache.get(obj.location_group_id)
+
 class ContainerDetailLogSerializer(serializers.ModelSerializer):
     batch = serializers.SerializerMethodField()
     container_code = serializers.SerializerMethodField()
@@ -383,7 +419,7 @@ class WCSTaskLogSerializer(serializers.ModelSerializer):
         model = WCSTaskLogModel
         fields = [
             'id', 'time', 'taskNumber', 'container', 'current_location', 
-            'target_location', 'location_group_id', 'access_priority', 
+            'target_location', 'location_group_id',
             'left_priority', 'right_priority', 'task_type', 'order_number', 
             'sequence', 'response_data', 'send_time', 'floor', 'is_completed', 'log_type'
         ]

+ 252 - 50
container/views.py

@@ -1,4 +1,3 @@
-from wsgiref import headers
 from rest_framework.viewsets import ViewSet
 from rest_framework import viewsets
 from utils.page import MyPageNumberPagination
@@ -48,6 +47,105 @@ loggertask = logging.getLogger('wms.WCSTask')
 from .models import DispatchConfig
 from .serializers import DispatchConfigSerializer
 
+DEFAULT_LOCATION_GROUP_ID = 0
+DEFAULT_ORDER_NUMBER = 0
+LOCATION_CODE_REGEX = re.compile(r'([A-Z0-9]+-L\d+C\d{3}-\d{2})', re.IGNORECASE)
+GROUP_CODE_REGEX = re.compile(r'([A-Z0-9]+-L\d+C\d{3})', re.IGNORECASE)
+COORDINATE_SUFFIX_REGEX = re.compile(r'(\d+)-(\d+)-(\d+)$')
+
+
+def select_reference_location(task_type, current_location, target_location):
+    """
+    根据任务类型确定需要解析的库位:入库/移库看目标,其余看当前
+    """
+    normalized = str(task_type or '').lower()
+    if normalized in ('inbound', 'move', 'putaway'):
+        return target_location or current_location
+    return current_location or target_location
+
+
+def _find_location_instance(location_str):
+    if not location_str:
+        return None
+    normalized = str(location_str).strip()
+    if not normalized:
+        return None
+    candidates = {normalized, normalized.upper()}
+    regex_match = LOCATION_CODE_REGEX.search(normalized)
+    if regex_match:
+        candidates.add(regex_match.group(1).upper())
+    for candidate in candidates:
+        location = LocationModel.objects.filter(
+            location_code=candidate
+        ).only('id', 'location_group', 'c_number').first()
+        if location:
+            return location
+    coordinate_match = COORDINATE_SUFFIX_REGEX.search(normalized)
+    if coordinate_match:
+        row, col, layer = coordinate_match.groups()
+        try:
+            return LocationModel.objects.filter(
+                row=int(row),
+                col=int(col),
+                layer=int(layer)
+            ).only('id', 'location_group', 'c_number').first()
+        except ValueError:
+            return None
+    return None
+
+
+def _extract_group_code(location_str):
+    if not location_str:
+        return None
+    match = GROUP_CODE_REGEX.search(location_str)
+    if match:
+        return match.group(1).upper()
+    parts = str(location_str).split('-')
+    if len(parts) > 1 and parts[-1].isdigit():
+        candidate = '-'.join(parts[:-1])
+        if GROUP_CODE_REGEX.match(candidate):
+            return candidate.upper()
+    return None
+
+
+def resolve_location_group_metadata(location_value):
+    defaults = {
+        'location_group_id': DEFAULT_LOCATION_GROUP_ID,
+        'order_number': DEFAULT_ORDER_NUMBER,
+    }
+    if location_value is None:
+        return defaults.copy()
+    location_str = str(location_value).strip()
+    if not location_str:
+        return defaults.copy()
+    location_instance = _find_location_instance(location_str)
+    if location_instance:
+        group_code = location_instance.location_group
+        group = LocationGroupModel.objects.filter(
+            group_code=group_code
+        ).only('id').first()
+        return {
+            'location_group_id': group.id if group else defaults['location_group_id'],
+            'order_number': (
+                location_instance.c_number
+            ) or defaults['order_number'],
+        }
+    group_code = _extract_group_code(location_str)
+    if group_code:
+        group = LocationGroupModel.objects.filter(
+            group_code=group_code
+        ).only('id').first()
+        if group:
+            return {
+                'location_group_id': group.id,
+                'order_number': defaults['order_number'],
+            }
+    return defaults.copy()
+
+
+def get_task_location_metadata(task_type, current_location, target_location):
+    reference_location = select_reference_location(task_type, current_location, target_location)
+    return resolve_location_group_metadata(reference_location)
 
 # 托盘分类视图
 # 借助LocationContainerLink,其中
@@ -126,7 +224,7 @@ class batchLogModelViewSet(viewsets.ModelViewSet):
 
     def create(self, request, *args, **kwargs):
         data = self.request.data
-        return Response(data, status=200, headers=headers)
+        return Response(data, status=200)
     
     def update(self, request, pk):
         qs = self.get_object()
@@ -191,7 +289,7 @@ class ContainerDetailLogModelViewSet(viewsets.ModelViewSet):
 
     def create(self, request, *args, **kwargs):
         data = self.request.data
-        return Response(data, status=200, headers=headers)
+        return Response(data, status=200)
     
     def update(self, request, pk):
         qs = self.get_object()
@@ -354,10 +452,17 @@ class WCSTaskViewSet(viewsets.ModelViewSet):
     def get_queryset(self):
         id = self.get_project()
         if self.request.user:
+            base_qs = ContainerWCSModel.objects.select_related(
+                'batch',
+                'batch_out',
+                'batch_out__batch_number',
+                'batch_out__bound_list',
+                'bound_list'
+            )
             if id is None:
-                return ContainerWCSModel.objects.filter()
+                return base_qs
             else:
-                return ContainerWCSModel.objects.filter(id=id)
+                return base_qs.filter(id=id)
         else:
             return ContainerWCSModel.objects.none()
 
@@ -472,7 +577,7 @@ class TaskViewSet(viewsets.ModelViewSet):
     def create(self, request, *args, **kwargs):
         data = self.request.data
 
-        return Response(data, status=200, headers=headers)
+        return Response(data, status=200)
     
     def update(self, request, pk):
         qs = self.get_object()
@@ -1137,6 +1242,7 @@ class ContainerWCSViewSet(viewsets.ModelViewSet):
             'status': 103,
             'is_delete': False
         }
+        data_tosave.update(get_task_location_metadata(tasktype, current_location, target_location))
 
         # 生成唯一递增的 taskid
         last_task = ContainerWCSModel.objects.filter(
@@ -1175,6 +1281,7 @@ class ContainerWCSViewSet(viewsets.ModelViewSet):
             'status': 103,
             'is_delete': False
         }
+        data_tosave.update(get_task_location_metadata(tasktype, current_location, target_location))
 
         # 生成唯一递增的 taskid
         last_task = ContainerWCSModel.objects.filter(
@@ -2148,6 +2255,22 @@ class OutboundService:
     @staticmethod
     def send_task_to_wcs(task):             
         """异步发送任务到WCS(非阻塞版本)"""
+        reference_location = select_reference_location(
+            task.tasktype,
+            task.current_location,
+            task.target_location
+        )
+        if task.location_group_id is None or task.order_number in (None, 0):
+            location_meta = resolve_location_group_metadata(reference_location)
+            update_fields = []
+            if task.location_group_id is None and location_meta['location_group_id'] is not None:
+                task.location_group_id = location_meta['location_group_id']
+                update_fields.append('location_group_id')
+            if task.order_number in (None, 0) and location_meta['order_number'] not in (None, 0):
+                task.order_number = location_meta['order_number']
+                update_fields.append('order_number')
+            if update_fields:
+                task.save(update_fields=update_fields)
         # 提取任务关键数据用于线程(避免直接传递ORM对象)
         task_data = {
             'task_id': task.pk,  # 使用主键而不是对象
@@ -2165,7 +2288,8 @@ class OutboundService:
                 "status": task.status,
                 "taskNumber": task.tasknumber-20000000000,
                 "order_number":task.order_number,
-                "sequence":task.sequence
+                "sequence":task.sequence,
+                "location_group_id": task.location_group_id or DEFAULT_LOCATION_GROUP_ID,
             }
             }
         }
@@ -2184,6 +2308,7 @@ class OutboundService:
                 'sequence': task.sequence,
                 'response_data': task_data,
                 'log_type': task.tasktype or 'outbound',
+                'location_group_id': task.location_group_id or DEFAULT_LOCATION_GROUP_ID,
             },
             daemon=True
         )
@@ -2197,37 +2322,83 @@ class OutboundService:
         )
         thread.start()
         return True  # 立即返回表示已开始处理
+    
+    @staticmethod
+    def _extract_layer(location):
+        """解析库位字符串中的楼层信息"""
+        try:
+            parts = str(location).split('-')
+            return parts[3] if len(parts) >= 4 else None
+        except Exception:
+            return None
+
+    @staticmethod
+    def _get_running_layers():
+        """获取当前正在执行中的出库任务所在楼层"""
+        running_locations = ContainerWCSModel.objects.filter(
+            tasktype='outbound',
+            status__gt=100,
+            status__lt=300,
+            working=1,
+            is_delete=False
+        ).values_list('current_location', flat=True)
+        active_layers = set()
+        for location in running_locations:
+            layer = OutboundService._extract_layer(location)
+            if layer:
+                active_layers.add(layer)
+        return active_layers
 
     @staticmethod
-    def _async_log_handler(task_number, container, current_location, target_location, task_type, order_number, sequence, response_data, log_type='outbound'):
+    def _async_log_handler(task_number, container, current_location, target_location, task_type, order_number, sequence, response_data, log_type='outbound', location_group_id=None):
         """异步记录 WCS 任务发送日志到数据库(不阻塞发送)"""
         try:
             close_old_connections()
             
             # 解析库位组与优先级
-            group_id = None
-            access_priority = None
+            group_id = location_group_id
+            order_number_value = order_number
             left_priority = None
             right_priority = None
             floor = None
-            try:
-                parts = current_location.split('-')
-                if len(parts) >= 4:
-                    row = int(parts[1])
-                    col = int(parts[2])
-                    layer = int(parts[3])
-                    floor = parts[3]
-                    loc = LocationModel.objects.filter(row=row, col=col, layer=layer).first()
-                    if loc:
-                        group_code = loc.location_group
-                        group = LocationGroupModel.objects.filter(group_code=group_code).first()
-                        if group:
-                            group_id = group.id
-                            access_priority = loc.c_number
-                            left_priority = group.left_priority
-                            right_priority = group.right_priority
-            except Exception as e:
-                logger.error(f"解析库位组信息失败: {e}")
+            group_obj = None
+            if group_id not in (None, 0):
+                group_obj = LocationGroupModel.objects.filter(
+                    id=group_id
+                ).only('id', 'group_code', 'left_priority', 'right_priority').first()
+                if group_obj:
+                    left_priority = group_obj.left_priority
+                    right_priority = group_obj.right_priority
+            if group_obj is None or order_number_value in (None, 0):
+                try:
+                    parts = current_location.split('-')
+                    if len(parts) >= 4:
+                        row = int(parts[1])
+                        col = int(parts[2])
+                        layer = int(parts[3])
+                        floor = parts[3]
+                        loc = LocationModel.objects.filter(row=row, col=col, layer=layer).only(
+                            'location_group', 'c_number'
+                        ).first()
+                        if loc:
+                            group = group_obj or LocationGroupModel.objects.filter(
+                                group_code=loc.location_group
+                            ).first()
+                            if group:
+                                group_obj = group
+                                group_id = group.id
+                                left_priority = group.left_priority
+                                right_priority = group.right_priority
+                            if order_number_value in (None, 0):
+                                order_number_value = loc.c_number or DEFAULT_ORDER_NUMBER
+                except Exception as e:
+                    logger.error(f"解析库位组信息失败: {e}")
+            if group_id in (None, 0) or order_number_value in (None, 0):
+                metadata = resolve_location_group_metadata(current_location)
+                if group_id in (None, 0):
+                    group_id = metadata['location_group_id']
+                if order_number_value in (None, 0):
+                    order_number_value = metadata.get('order_number', DEFAULT_ORDER_NUMBER)
             
             # 创建日志记录
             WCSTaskLogModel.objects.create(
@@ -2235,12 +2406,11 @@ class OutboundService:
                 container=container,
                 current_location=current_location,
                 target_location=target_location,
-                location_group_id=group_id,
-                access_priority=access_priority,
+                location_group_id=group_id or DEFAULT_LOCATION_GROUP_ID,
                 left_priority=left_priority,
                 right_priority=right_priority,
                 task_type=task_type,
-                order_number=order_number,
+                order_number=order_number_value or DEFAULT_ORDER_NUMBER,
                 sequence=sequence,
                 response_data=response_data,
                 floor=floor,
@@ -2344,13 +2514,18 @@ class OutboundService:
                         "msg": f"批次 {container['batch_id']} 不存在",
                     }
                 month = int(timezone.now().strftime("%Y%m"))
+                location_meta = get_task_location_metadata(
+                    "outbound",
+                    container_obj.current_location,
+                    "103"
+                )
                 task = ContainerWCSModel(
                     taskid=OutboundService.generate_task_id(),
                     batch = OutBoundDetail_obj.bound_batch_number,
                     batch_out = OutBoundDetail_obj.bound_batch,
                     bound_list = OutBoundDetail_obj.bound_list,
                     sequence=index,
-                    order_number = container['c_number'],
+                    order_number = container.get('c_number') or location_meta['order_number'] or DEFAULT_ORDER_NUMBER,
                     priority=100,
                     tasknumber = month*100000+tasknumber_index+tasknumber,
                     container=container_obj.container_code,
@@ -2360,6 +2535,7 @@ class OutboundService:
                     month=int(timezone.now().strftime("%Y%m")),
                     message="等待出库",
                     status=100,
+                    location_group_id=location_meta['location_group_id'],
                 )
                 layer = None
                 try:
@@ -2402,13 +2578,18 @@ class OutboundService:
                     return False
                 batch_obj = BoundBatchModel.objects.filter(id =batch_id).first()
                 month = int(timezone.now().strftime("%Y%m"))
+                location_meta = get_task_location_metadata(
+                    "check",
+                    container_obj.current_location,
+                    "103"
+                )
                 task = ContainerWCSModel(
                     taskid=OutboundService.generate_task_id(),
                     batch = batch_obj,
                     batch_out = None,
                     bound_list = None,
                     sequence=index,
-                    order_number = container['c_number'],
+                    order_number = container.get('c_number') or location_meta['order_number'] or DEFAULT_ORDER_NUMBER,
                     priority=100,
                     tasknumber = month*100000+tasknumber_index+tasknumber,
                     container=container_obj.container_code,
@@ -2418,6 +2599,7 @@ class OutboundService:
                     month=int(timezone.now().strftime("%Y%m")),
                     message="等待出库",
                     status=100,
+                    location_group_id=location_meta['location_group_id'],
                 )
                 tasknumber_index += 1
                 tasks.append(task)
@@ -2435,16 +2617,29 @@ class OutboundService:
             
             # 插入新任务
             for new_task_data in new_tasks:
+                target_location = new_task_data.get('target_location', 'OUT01')
+                location_meta = get_task_location_metadata(
+                    "outbound",
+                    new_task_data['current_location'],
+                    target_location
+                )
+                order_number = new_task_data.get('order_number')
+                if order_number in (None, 0):
+                    order_number = location_meta['order_number']
+                if order_number in (None, 0):
+                    order_number = DEFAULT_ORDER_NUMBER
                 new_task = ContainerWCSModel(
                     taskid=OutboundService.generate_task_id(),
                     priority=new_task_data.get('priority', 100),
+                    order_number=order_number,
                     container=new_task_data['container'],
                     current_location=new_task_data['current_location'],
-                    target_location=new_task_data.get('target_location', 'OUT01'),
+                    target_location=target_location,
                     tasktype="outbound",
                     month=int(timezone.now().strftime("%Y%m")),
                     message="等待出库",
                     status=100,
+                    location_group_id=location_meta['location_group_id'],
                 )
                 # 找到插入位置
                 insert_pos = 0
@@ -2496,14 +2691,6 @@ class OutboundService:
                 )
             ).order_by('batch_out_id_for_sort', 'sequence')
         
-        def extract_layer(location):
-            """从位置字符串中提取楼层信息"""
-            try:
-                parts = str(location).split('-')
-                return parts[3] if len(parts) >= 4 else None
-            except Exception:
-                return None
-        
         pending_tasks = get_pending_tasks()
         
         if not pending_tasks.exists():
@@ -2514,6 +2701,7 @@ class OutboundService:
         cfg = DispatchConfig.get_active_config()
         cross_floor_limit = max(1, int(cfg.cross_floor_concurrent_limit or 2))
         desired_layers = set(initial_layers or [])
+        active_layers = OutboundService._get_running_layers()
         # 处理任务列表
         # 如果single_task=True,只下发1条;否则批量下发(最多cross_floor_limit条)
         processed_count = 0
@@ -2525,25 +2713,32 @@ class OutboundService:
         max_skip = max(20, len(desired_layers) * 5)
         dispatched_ids = set()
         used_layers = set()
+        blocked_layers = set()
         
         while processed_count < max_tasks and skip_count < max_skip:
             # 重新获取待处理任务(因为可能有任务被跳过)
             pending_tasks = get_pending_tasks().exclude(pk__in=dispatched_ids)
             if not pending_tasks.exists():
                 break
-            if desired_layers and used_layers.issuperset(desired_layers):
-                logger.info("已完成初始多楼层任务的分发")
-                break
+            if desired_layers:
+                effective_layers = desired_layers - blocked_layers
+                if not effective_layers:
+                    logger.info("初始楼层均存在执行中的任务,暂不新增同楼层下发")
+                    break
+                if used_layers.issuperset(effective_layers):
+                    logger.info("已完成初始多楼层任务的分发")
+                    break
             
             # 如果single_task=True且提供了preferred_layer,优先选择同楼层的任务
             next_task = None
             if desired_layers:
-                remaining_layers = desired_layers - used_layers
-                target_layers = remaining_layers if remaining_layers else desired_layers
+                effective_layers = desired_layers - blocked_layers
+                remaining_layers = effective_layers - used_layers
+                target_layers = remaining_layers if remaining_layers else effective_layers
                 prioritized = []
                 fallback = []
                 for task in pending_tasks:
-                    task_layer = extract_layer(task.current_location)
+                    task_layer = OutboundService._extract_layer(task.current_location)
                     if task_layer in target_layers:
                         prioritized.append(task)
                     else:
@@ -2560,7 +2755,7 @@ class OutboundService:
                 same_layer_tasks = []
                 other_layer_tasks = []
                 for task in pending_tasks:
-                    task_layer = extract_layer(task.current_location)
+                    task_layer = OutboundService._extract_layer(task.current_location)
                     if task_layer == preferred_layer:
                         same_layer_tasks.append(task)
                     else:
@@ -2586,7 +2781,7 @@ class OutboundService:
             dispatched_ids.add(next_task.pk)
             location = next_task.current_location
             # 解析楼层(假设格式 Wxx-row-col-layer)
-            task_layer = extract_layer(location)
+            task_layer = OutboundService._extract_layer(location)
             
             if location == '103' or location == '203':
                 logger.info(f"需要跳过该任务: {next_task.taskid}, 位置: {location}")
@@ -2597,6 +2792,12 @@ class OutboundService:
                 # 跳过这个任务后,继续处理下一个
                 continue
             
+            if task_layer and task_layer in active_layers:
+                logger.info(f"楼层 {task_layer} 已有执行中的任务,跳过下发: {next_task.taskid}")
+                blocked_layers.add(task_layer)
+                skip_count += 1
+                continue
+            
             # 跨楼层并发控制:同一轮不允许重复楼层(仅批量下发时生效)
             if not single_task and not desired_layers and task_layer and task_layer in used_layers:
                 skip_count += 1
@@ -2619,6 +2820,7 @@ class OutboundService:
                 processed_count += 1
                 if task_layer:
                     used_layers.add(task_layer)
+                    active_layers.add(task_layer)
                 logger.info(f"成功下发任务: {next_task.taskid}, 批次: {next_task.batch_out_id if next_task.batch_out else '无批次'}")
             except Exception as e:
                 logger.error(f"任务处理失败: {next_task.taskid}, 错误: {str(e)}")

+ 0 - 0
cyclecount/__init__.py


+ 60 - 0
cyclecount/admin.py

@@ -0,0 +1,60 @@
+from django.contrib import admin
+
+from .models import CountReason, CountTask, CountTaskDetail
+
+
+@admin.register(CountReason)
+class CountReasonAdmin(admin.ModelAdmin):
+    list_display = ("code", "description", "is_active", "sort", "update_time")
+    list_filter = ("is_active",)
+    search_fields = ("code", "description")
+    ordering = ("sort", "code")
+
+
+class CountTaskDetailInline(admin.TabularInline):
+    model = CountTaskDetail
+    extra = 0
+    readonly_fields = (
+        "container_code",
+        "goods_code",
+        "book_qty",
+        "initial_count_qty",
+        "recount_qty",
+        "final_qty",
+        "variance_qty",
+        "status",
+    )
+
+
+@admin.register(CountTask)
+class CountTaskAdmin(admin.ModelAdmin):
+    list_display = (
+        "doc_no",
+        "task_type",
+        "status",
+        "total_details",
+        "counted_details",
+        "variance_details",
+        "create_time",
+    )
+    list_filter = ("task_type", "status")
+    search_fields = ("doc_no",)
+    readonly_fields = ("total_details", "counted_details", "variance_details")
+    inlines = (CountTaskDetailInline,)
+
+
+@admin.register(CountTaskDetail)
+class CountTaskDetailAdmin(admin.ModelAdmin):
+    list_display = (
+        "task",
+        "container_code",
+        "goods_code",
+        "book_qty",
+        "initial_count_qty",
+        "recount_qty",
+        "variance_qty",
+        "status",
+    )
+    list_filter = ("status",)
+    search_fields = ("container_code", "goods_code", "task__doc_no")
+

+ 8 - 0
cyclecount/apps.py

@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class CyclecountConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'cyclecount'
+    verbose_name = "Cycle Count"
+

+ 95 - 0
cyclecount/migrations/0001_initial.py

@@ -0,0 +1,95 @@
+from decimal import Decimal
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ("container", "0038_wcstasklogmodel_floor_wcstasklogmodel_is_completed_and_more"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CountReason",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("code", models.CharField(max_length=32, unique=True, verbose_name="原因编码")),
+                ("description", models.CharField(max_length=255, verbose_name="原因说明")),
+                ("is_active", models.BooleanField(default=True, verbose_name="是否启用")),
+                ("sort", models.IntegerField(default=0, verbose_name="排序")),
+                ("note", models.CharField(blank=True, max_length=255, verbose_name="备注")),
+                ("create_time", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
+                ("update_time", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
+            ],
+            options={
+                "verbose_name": "盘点差异原因",
+                "verbose_name_plural": "盘点差异原因",
+                "db_table": "cycle_count_reason",
+                "ordering": ["sort", "code"],
+            },
+        ),
+        migrations.CreateModel(
+            name="CountTask",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("doc_no", models.CharField(blank=True, help_text="自动生成,也可手工录入", max_length=40, unique=True, verbose_name="任务单号")),
+                ("warehouse_code", models.CharField(max_length=64, verbose_name="仓库编码")),
+                ("warehouse_name", models.CharField(blank=True, max_length=128, verbose_name="仓库名称")),
+                ("task_type", models.CharField(choices=[("full", "全盘"), ("sample", "抽盘"), ("adhoc", "临盘")], default="full", max_length=16, verbose_name="任务类型")),
+                ("status", models.CharField(choices=[("draft", "草稿"), ("released", "已下发"), ("in_progress", "进行中"), ("wait_recount", "待复盘"), ("completed", "已完成"), ("closed", "已关闭")], default="draft", max_length=20, verbose_name="状态")),
+                ("source_batch", models.CharField(blank=True, max_length=64, verbose_name="来源批次")),
+                ("remark", models.CharField(blank=True, max_length=255, verbose_name="备注")),
+                ("scheduled_at", models.DateTimeField(blank=True, null=True, verbose_name="计划盘点时间")),
+                ("created_by", models.CharField(blank=True, max_length=64, verbose_name="创建人")),
+                ("total_details", models.IntegerField(default=0, verbose_name="明细数量")),
+                ("counted_details", models.IntegerField(default=0, verbose_name="已盘数量")),
+                ("variance_details", models.IntegerField(default=0, verbose_name="差异数量")),
+                ("create_time", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
+                ("update_time", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
+            ],
+            options={
+                "verbose_name": "盘点任务",
+                "verbose_name_plural": "盘点任务",
+                "db_table": "cycle_count_task",
+                "ordering": ["-id"],
+            },
+        ),
+        migrations.CreateModel(
+            name="CountTaskDetail",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("container_code", models.CharField(blank=True, max_length=64, verbose_name="托盘编码")),
+                ("location_code", models.CharField(blank=True, max_length=64, verbose_name="库位")),
+                ("goods_code", models.CharField(blank=True, max_length=64, verbose_name="物料编码")),
+                ("goods_desc", models.CharField(blank=True, max_length=255, verbose_name="物料名称")),
+                ("goods_std", models.CharField(blank=True, max_length=255, verbose_name="规格型号")),
+                ("goods_unit", models.CharField(blank=True, max_length=32, verbose_name="单位")),
+                ("batch_number", models.CharField(blank=True, max_length=64, verbose_name="管理批次")),
+                ("book_qty", models.DecimalField(decimal_places=3, default=Decimal("0"), max_digits=14, verbose_name="账面数量")),
+                ("initial_count_qty", models.DecimalField(blank=True, decimal_places=3, max_digits=14, null=True, verbose_name="初盘数量")),
+                ("recount_qty", models.DecimalField(blank=True, decimal_places=3, max_digits=14, null=True, verbose_name="复盘数量")),
+                ("final_qty", models.DecimalField(decimal_places=3, default=Decimal("0"), max_digits=14, verbose_name="最终数量")),
+                ("variance_qty", models.DecimalField(decimal_places=3, default=Decimal("0"), max_digits=14, verbose_name="盘差数量")),
+                ("counter_name", models.CharField(blank=True, max_length=64, verbose_name="初盘人")),
+                ("recount_counter", models.CharField(blank=True, max_length=64, verbose_name="复盘人")),
+                ("note", models.CharField(blank=True, max_length=255, verbose_name="备注")),
+                ("status", models.CharField(choices=[("pending", "待盘点"), ("counted", "已初盘"), ("variance", "待复盘"), ("completed", "已完成")], default="pending", max_length=20, verbose_name="状态")),
+                ("create_time", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
+                ("update_time", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
+                ("container", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="count_details", to="container.containerlistmodel", verbose_name="托盘")),
+                ("reason", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="details", to="cyclecount.countreason", verbose_name="差异原因")),
+                ("task", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="details", to="cyclecount.counttask", verbose_name="任务")),
+            ],
+            options={
+                "verbose_name": "盘点任务明细",
+                "verbose_name_plural": "盘点任务明细",
+                "db_table": "cycle_count_task_detail",
+                "ordering": ["id"],
+            },
+        ),
+    ]
+

+ 20 - 0
cyclecount/migrations/0002_remove_counttask_warehouse_fields.py

@@ -0,0 +1,20 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("cyclecount", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="counttask",
+            name="warehouse_code",
+        ),
+        migrations.RemoveField(
+            model_name="counttask",
+            name="warehouse_name",
+        ),
+    ]
+

+ 0 - 0
cyclecount/migrations/__init__.py


+ 254 - 0
cyclecount/models.py

@@ -0,0 +1,254 @@
+from decimal import Decimal
+
+from django.db import models
+from django.db.models import Count, Q
+from django.utils import timezone
+
+from container.models import ContainerListModel
+
+
+class CountReason(models.Model):
+    """Variance reason master data."""
+
+    code = models.CharField(max_length=32, unique=True, verbose_name="原因编码")
+    description = models.CharField(max_length=255, verbose_name="原因说明")
+    is_active = models.BooleanField(default=True, verbose_name="是否启用")
+    sort = models.IntegerField(default=0, verbose_name="排序")
+    note = models.CharField(max_length=255, blank=True, verbose_name="备注")
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+    update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
+
+    class Meta:
+        db_table = "cycle_count_reason"
+        verbose_name = "盘点差异原因"
+        verbose_name_plural = "盘点差异原因"
+        ordering = ["sort", "code"]
+
+    def __str__(self) -> str:
+        return f"{self.code}-{self.description}"
+
+
+class CountTask(models.Model):
+    """Cycle count task header."""
+
+    TASK_FULL = "full"
+    TASK_SAMPLE = "sample"
+    TASK_ADHOC = "adhoc"
+    TASK_TYPE_CHOICES = (
+        (TASK_FULL, "全盘"),
+        (TASK_SAMPLE, "抽盘"),
+        (TASK_ADHOC, "临盘"),
+    )
+
+    STATUS_DRAFT = "draft"
+    STATUS_RELEASED = "released"
+    STATUS_IN_PROGRESS = "in_progress"
+    STATUS_WAIT_RECOUNT = "wait_recount"
+    STATUS_COMPLETED = "completed"
+    STATUS_CLOSED = "closed"
+    STATUS_CHOICES = (
+        (STATUS_DRAFT, "草稿"),
+        (STATUS_RELEASED, "已下发"),
+        (STATUS_IN_PROGRESS, "进行中"),
+        (STATUS_WAIT_RECOUNT, "待复盘"),
+        (STATUS_COMPLETED, "已完成"),
+        (STATUS_CLOSED, "已关闭"),
+    )
+
+    doc_no = models.CharField(
+        max_length=40,
+        unique=True,
+        blank=True,
+        verbose_name="任务单号",
+        help_text="自动生成,也可手工录入",
+    )
+    task_type = models.CharField(
+        max_length=16, choices=TASK_TYPE_CHOICES, default=TASK_FULL, verbose_name="任务类型"
+    )
+    status = models.CharField(
+        max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name="状态"
+    )
+    source_batch = models.CharField(max_length=64, blank=True, verbose_name="来源批次")
+    remark = models.CharField(max_length=255, blank=True, verbose_name="备注")
+    scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name="计划盘点时间")
+    created_by = models.CharField(max_length=64, blank=True, verbose_name="创建人")
+    total_details = models.IntegerField(default=0, verbose_name="明细数量")
+    counted_details = models.IntegerField(default=0, verbose_name="已盘数量")
+    variance_details = models.IntegerField(default=0, verbose_name="差异数量")
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+    update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
+
+    class Meta:
+        db_table = "cycle_count_task"
+        verbose_name = "盘点任务"
+        verbose_name_plural = "盘点任务"
+        ordering = ["-id"]
+
+    def __str__(self) -> str:
+        return self.doc_no
+
+    @staticmethod
+    def generate_doc_no() -> str:
+        """Generate a readable unique code."""
+        return timezone.now().strftime("CC%Y%m%d%H%M%S%f")
+
+    def save(self, *args, **kwargs):
+        if not self.doc_no:
+            self.doc_no = self.generate_doc_no()
+        super().save(*args, **kwargs)
+
+    def refresh_statistics(self):
+        """Refresh counters & status based on detail lines."""
+        aggregates = self.details.aggregate(
+            total=Count("id"),
+            counted=Count(
+                "id",
+                filter=Q(initial_count_qty__isnull=False)
+                | Q(recount_qty__isnull=False),
+            ),
+            variance=Count(
+                "id",
+                filter=Q(variance_qty__isnull=False)
+                & ~Q(variance_qty=Decimal("0")),
+            ),
+        )
+        total = aggregates.get("total") or 0
+        counted = aggregates.get("counted") or 0
+        variance = aggregates.get("variance") or 0
+
+        current_status = self.status
+
+        if total == 0:
+            new_status = self.STATUS_DRAFT
+        elif counted == 0:
+            # 仍处于草稿态的任务不因生成/删除明细而自动下发
+            if current_status == self.STATUS_DRAFT:
+                new_status = self.STATUS_DRAFT
+            else:
+                new_status = self.STATUS_RELEASED
+        elif counted < total:
+            new_status = self.STATUS_IN_PROGRESS
+        elif variance > 0:
+            new_status = self.STATUS_WAIT_RECOUNT
+        else:
+            new_status = self.STATUS_COMPLETED
+
+        CountTask.objects.filter(pk=self.pk).update(
+            total_details=total,
+            counted_details=counted,
+            variance_details=variance,
+            status=new_status,
+            update_time=timezone.now(),
+        )
+        self.refresh_from_db(fields=["total_details", "counted_details", "variance_details", "status"])
+
+
+class CountTaskDetail(models.Model):
+    """Cycle count detail lines."""
+
+    STATUS_PENDING = "pending"
+    STATUS_COUNTED = "counted"
+    STATUS_VARIANCE = "variance"
+    STATUS_COMPLETED = "completed"
+    STATUS_CHOICES = (
+        (STATUS_PENDING, "待盘点"),
+        (STATUS_COUNTED, "已初盘"),
+        (STATUS_VARIANCE, "待复盘"),
+        (STATUS_COMPLETED, "已完成"),
+    )
+
+    task = models.ForeignKey(
+        CountTask, related_name="details", on_delete=models.CASCADE, verbose_name="任务"
+    )
+    container = models.ForeignKey(
+        ContainerListModel,
+        related_name="count_details",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        verbose_name="托盘",
+    )
+    container_code = models.CharField(max_length=64, blank=True, verbose_name="托盘编码")
+    location_code = models.CharField(max_length=64, blank=True, verbose_name="库位")
+    goods_code = models.CharField(max_length=64, blank=True, verbose_name="物料编码")
+    goods_desc = models.CharField(max_length=255, blank=True, verbose_name="物料名称")
+    goods_std = models.CharField(max_length=255, blank=True, verbose_name="规格型号")
+    goods_unit = models.CharField(max_length=32, blank=True, verbose_name="单位")
+    batch_number = models.CharField(max_length=64, blank=True, verbose_name="管理批次")
+    book_qty = models.DecimalField(
+        max_digits=14, decimal_places=3, default=Decimal("0"), verbose_name="账面数量"
+    )
+    initial_count_qty = models.DecimalField(
+        max_digits=14, decimal_places=3, null=True, blank=True, verbose_name="初盘数量"
+    )
+    recount_qty = models.DecimalField(
+        max_digits=14, decimal_places=3, null=True, blank=True, verbose_name="复盘数量"
+    )
+    final_qty = models.DecimalField(
+        max_digits=14, decimal_places=3, default=Decimal("0"), verbose_name="最终数量"
+    )
+    variance_qty = models.DecimalField(
+        max_digits=14, decimal_places=3, default=Decimal("0"), verbose_name="盘差数量"
+    )
+    reason = models.ForeignKey(
+        CountReason,
+        related_name="details",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+        verbose_name="差异原因",
+    )
+    counter_name = models.CharField(max_length=64, blank=True, verbose_name="初盘人")
+    recount_counter = models.CharField(max_length=64, blank=True, verbose_name="复盘人")
+    note = models.CharField(max_length=255, blank=True, verbose_name="备注")
+    status = models.CharField(
+        max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, verbose_name="状态"
+    )
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+    update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
+
+    class Meta:
+        db_table = "cycle_count_task_detail"
+        verbose_name = "盘点任务明细"
+        verbose_name_plural = "盘点任务明细"
+        ordering = ["id"]
+
+    def __str__(self) -> str:
+        return f"{self.task.doc_no}-{self.container_code or self.goods_code}"
+
+    def sync_qty_fields(self):
+        """Recalculate variance & final qty and derive status."""
+        book_qty = self.book_qty or Decimal("0")
+        final_qty = None
+        if self.recount_qty is not None:
+            final_qty = self.recount_qty
+        elif self.initial_count_qty is not None:
+            final_qty = self.initial_count_qty
+        else:
+            final_qty = book_qty
+
+        self.final_qty = final_qty
+        self.variance_qty = (final_qty or Decimal("0")) - book_qty
+
+        if self.recount_qty is not None:
+            self.status = self.STATUS_COMPLETED
+        elif self.initial_count_qty is not None:
+            if self.variance_qty == Decimal("0"):
+                self.status = self.STATUS_COUNTED
+            else:
+                self.status = self.STATUS_VARIANCE
+        else:
+            self.status = self.STATUS_PENDING
+
+    def save(self, *args, **kwargs):
+        self.sync_qty_fields()
+        super().save(*args, **kwargs)
+        if self.task_id:
+            self.task.refresh_statistics()
+
+    def delete(self, *args, **kwargs):
+        task = self.task
+        super().delete(*args, **kwargs)
+        if task:
+            task.refresh_statistics()
+

+ 123 - 0
cyclecount/serializers.py

@@ -0,0 +1,123 @@
+from rest_framework import serializers
+
+from .models import CountReason, CountTask, CountTaskDetail
+
+
+class CountReasonSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = CountReason
+        fields = "__all__"
+        read_only_fields = ("id", "create_time", "update_time")
+
+
+class CountTaskDetailSerializer(serializers.ModelSerializer):
+    reason = CountReasonSerializer(read_only=True)
+    reason_id = serializers.PrimaryKeyRelatedField(
+        queryset=CountReason.objects.all(),
+        source="reason",
+        required=False,
+        allow_null=True,
+        write_only=True,
+    )
+
+    class Meta:
+        model = CountTaskDetail
+        fields = (
+            "id",
+            "task",
+            "container",
+            "container_code",
+            "location_code",
+            "goods_code",
+            "goods_desc",
+            "goods_std",
+            "goods_unit",
+            "batch_number",
+            "book_qty",
+            "initial_count_qty",
+            "recount_qty",
+            "final_qty",
+            "variance_qty",
+            "reason",
+            "reason_id",
+            "counter_name",
+            "recount_counter",
+            "note",
+            "status",
+            "create_time",
+            "update_time",
+        )
+        read_only_fields = (
+            "id",
+            "final_qty",
+            "variance_qty",
+            "status",
+            "create_time",
+            "update_time",
+        )
+
+
+class CountTaskSerializer(serializers.ModelSerializer):
+    details = CountTaskDetailSerializer(many=True, read_only=True)
+
+    class Meta:
+        model = CountTask
+        fields = (
+            "id",
+            "doc_no",
+            "task_type",
+            "status",
+            "source_batch",
+            "remark",
+            "scheduled_at",
+            "created_by",
+            "total_details",
+            "counted_details",
+            "variance_details",
+            "create_time",
+            "update_time",
+            "details",
+        )
+        read_only_fields = (
+            "id",
+            "doc_no",
+            "status",
+            "total_details",
+            "counted_details",
+            "variance_details",
+            "create_time",
+            "update_time",
+        )
+
+
+class TaskGenerateDetailSerializer(serializers.Serializer):
+    container_ids = serializers.ListField(
+        child=serializers.IntegerField(min_value=1), allow_empty=False, required=False
+    )
+    batch_ids = serializers.ListField(
+        child=serializers.IntegerField(min_value=1), allow_empty=False, required=False
+    )
+    override = serializers.BooleanField(default=False)
+
+    def validate(self, attrs):
+        container_ids = attrs.get("container_ids") or []
+        batch_ids = attrs.get("batch_ids") or []
+        if not container_ids and not batch_ids:
+            raise serializers.ValidationError("请至少选择托盘或批次")
+        return attrs
+
+
+class CountDetailSubmitSerializer(serializers.Serializer):
+    ROUND_INITIAL = "initial"
+    ROUND_RECOUNT = "recount"
+
+    round_type = serializers.ChoiceField(
+        choices=((ROUND_INITIAL, "初盘"), (ROUND_RECOUNT, "复盘")), default=ROUND_INITIAL
+    )
+    qty = serializers.DecimalField(max_digits=14, decimal_places=3)
+    reason_id = serializers.PrimaryKeyRelatedField(
+        queryset=CountReason.objects.all(), allow_null=True, required=False
+    )
+    counter_name = serializers.CharField(required=False, allow_blank=True)
+    note = serializers.CharField(required=False, allow_blank=True)
+

+ 78 - 0
cyclecount/urls.py

@@ -0,0 +1,78 @@
+from django.urls import path, re_path
+
+from . import views
+
+urlpatterns = [
+    path(
+        "reasons/",
+        views.CountReasonViewSet.as_view({"get": "list", "post": "create"}),
+        name="countreason-list",
+    ),
+    re_path(
+        r"^reasons/(?P<pk>\d+)/$",
+        views.CountReasonViewSet.as_view(
+            {
+                "get": "retrieve",
+                "put": "update",
+                "patch": "partial_update",
+                "delete": "destroy",
+            }
+        ),
+        name="countreason-detail",
+    ),
+    path(
+        "tasks/",
+        views.CountTaskViewSet.as_view({"get": "list", "post": "create"}),
+        name="counttask-list",
+    ),
+    re_path(
+        r"^tasks/(?P<pk>\d+)/$",
+        views.CountTaskViewSet.as_view(
+            {
+                "get": "retrieve",
+                "put": "update",
+                "patch": "partial_update",
+                "delete": "destroy",
+            }
+        ),
+        name="counttask-detail",
+    ),
+    re_path(
+        r"^tasks/(?P<pk>\d+)/generate_details/$",
+        views.CountTaskViewSet.as_view({"post": "generate_details"}),
+        name="counttask-generate-details",
+    ),
+    re_path(
+        r"^tasks/(?P<pk>\d+)/download_report/$",
+        views.CountTaskViewSet.as_view({"get": "download_report"}),
+        name="counttask-download",
+    ),
+    re_path(
+        r"^tasks/(?P<pk>\d+)/release/$",
+        views.CountTaskViewSet.as_view({"post": "release"}),
+        name="counttask-release",
+    ),
+    path(
+        "details/",
+        views.CountTaskDetailViewSet.as_view({"get": "list", "post": "create"}),
+        name="counttaskdetail-list",
+    ),
+    re_path(
+        r"^details/(?P<pk>\d+)/$",
+        views.CountTaskDetailViewSet.as_view(
+            {
+                "get": "retrieve",
+                "put": "update",
+                "patch": "partial_update",
+                "delete": "destroy",
+            }
+        ),
+        name="counttaskdetail-detail",
+    ),
+    re_path(
+        r"^details/(?P<pk>\d+)/count/$",
+        views.CountTaskDetailViewSet.as_view({"post": "submit_count"}),
+        name="counttaskdetail-count",
+    ),
+]
+

+ 357 - 0
cyclecount/views.py

@@ -0,0 +1,357 @@
+import csv
+from collections import defaultdict
+from decimal import Decimal
+
+from django.db import transaction
+from django.http import HttpResponse
+from rest_framework import status, viewsets
+from rest_framework.decorators import action
+from rest_framework.filters import OrderingFilter
+from rest_framework.response import Response
+from django_filters.rest_framework import DjangoFilterBackend
+
+from utils.page import MyPageNumberPagination
+from bin.models import LocationContainerLink
+from bound.models import BoundBatchModel
+from container.models import ContainerListModel, ContainerDetailModel
+from container.views import OutboundService
+from .models import CountReason, CountTask, CountTaskDetail
+from .serializers import (
+    CountDetailSubmitSerializer,
+    CountReasonSerializer,
+    CountTaskDetailSerializer,
+    CountTaskSerializer,
+    TaskGenerateDetailSerializer,
+)
+
+
+class CountReasonViewSet(viewsets.ModelViewSet):
+    """Master data for variance reasons."""
+
+    queryset = CountReason.objects.all().order_by("sort", "code")
+    serializer_class = CountReasonSerializer
+    pagination_class = None
+    filter_backends = [OrderingFilter]
+    ordering_fields = ["sort", "code", "update_time"]
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        flag = self.request.query_params.get("active_only")
+        if flag in ("1", "true", "True"):
+            qs = qs.filter(is_active=True)
+        return qs
+
+
+class CountTaskViewSet(viewsets.ModelViewSet):
+    """Cycle count task CRUD."""
+
+    queryset = CountTask.objects.prefetch_related("details", "details__reason").order_by("-id")
+    serializer_class = CountTaskSerializer
+    pagination_class = MyPageNumberPagination
+    filter_backends = [DjangoFilterBackend, OrderingFilter]
+    filterset_fields = ["status", "task_type"]
+    ordering_fields = ["id", "create_time", "update_time", "scheduled_at"]
+
+    @action(detail=True, methods=["post"])
+    def generate_details(self, request, pk=None):
+        task = self.get_object()
+        serializer = TaskGenerateDetailSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        container_ids = serializer.validated_data.get("container_ids") or []
+        batch_ids = serializer.validated_data.get("batch_ids") or []
+        override = serializer.validated_data.get("override", False)
+
+        containers = list(ContainerListModel.objects.filter(id__in=container_ids))
+        if container_ids and not containers:
+            return Response({"detail": "未找到托盘数据"}, status=status.HTTP_400_BAD_REQUEST)
+
+        payloads = {}
+
+        def add_payload(container, batch_number, goods_meta, qty):
+            qty = qty if isinstance(qty, Decimal) else Decimal(str(qty or "0"))
+            if qty <= 0:
+                return
+            normalized_batch = batch_number or ""
+            if normalized_batch:
+                normalized_batch = str(normalized_batch)
+            key = (container.id if container else None, normalized_batch)
+            base_payload = dict(
+                task=task,
+                container=container,
+                container_code=str(container.container_code) if container else "",
+                location_code=container.current_location if container else "",
+                goods_code=goods_meta.get("goods_code", ""),
+                goods_desc=goods_meta.get("goods_desc", ""),
+                goods_std=goods_meta.get("goods_std", ""),
+                goods_unit=goods_meta.get("goods_unit", ""),
+                batch_number=normalized_batch,
+                book_qty=qty,
+                initial_count_qty=None,
+                recount_qty=None,
+                reason=None,
+                counter_name="",
+                recount_counter="",
+                note="",
+                final_qty=qty,
+                variance_qty=Decimal("0"),
+                status=CountTaskDetail.STATUS_PENDING,
+            )
+            if key in payloads:
+                payloads[key]["book_qty"] += qty
+                payloads[key]["final_qty"] = payloads[key]["book_qty"]
+            else:
+                payloads[key] = base_payload
+
+        # 1) 托盘选取
+        for container in containers:
+            raw_batch_info = container.batch_info or []
+            batch_info = raw_batch_info
+            if batch_ids:
+                batch_info = [info for info in raw_batch_info if info.get("batch_id") in batch_ids]
+            if not batch_info:
+                batch_info = raw_batch_info[:1] if raw_batch_info else [{}]
+            for info in batch_info:
+                goods_qty = info.get("qty", info.get("goods_qty", 0))
+                goods_meta = {
+                    "goods_code": info.get("goods_code", ""),
+                    "goods_desc": info.get("goods_desc", ""),
+                    "goods_std": info.get("goods_std", ""),
+                    "goods_unit": info.get("goods_unit", ""),
+                }
+                batch_number = info.get("bound_number") or info.get("batch_number", "")
+                add_payload(container, batch_number, goods_meta, goods_qty)
+
+        # 2) 批次选取
+        if batch_ids:
+            batch_details = (
+                ContainerDetailModel.objects.select_related("container", "batch")
+                .filter(batch_id__in=batch_ids, is_delete=False)
+                .exclude(status=3)
+            )
+            for detail in batch_details:
+                container = detail.container
+                if not container:
+                    continue
+                available_qty = (detail.goods_qty or Decimal("0")) - (detail.goods_out_qty or Decimal("0"))
+                batch = detail.batch
+                goods_meta = {
+                    "goods_code": detail.goods_code,
+                    "goods_desc": detail.goods_desc,
+                    "goods_std": batch.goods_std if batch else "",
+                    "goods_unit": batch.goods_unit if batch else "",
+                }
+                batch_number = batch.bound_number if batch else ""
+                add_payload(container, batch_number, goods_meta, available_qty)
+
+        if not payloads:
+            return Response(
+                {"detail": "未找到符合条件的托盘或批次"},
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        existing_details = {
+            (detail.container_id, detail.batch_number or ""): detail for detail in task.details.all()
+        }
+
+        to_create = []
+        created = 0
+        updated = 0
+
+        with transaction.atomic():
+            for key, payload in payloads.items():
+                if key in existing_details:
+                    if override:
+                        detail = existing_details[key]
+                        for field, value in payload.items():
+                            setattr(detail, field, value)
+                        detail.save()
+                        updated += 1
+                    continue
+                to_create.append(CountTaskDetail(**payload))
+
+            if to_create:
+                CountTaskDetail.objects.bulk_create(to_create)
+                created = len(to_create)
+
+        task.refresh_statistics()
+        return Response(
+            {"created": created, "updated": updated, "total": task.total_details},
+            status=status.HTTP_201_CREATED,
+        )
+
+    @action(detail=True, methods=["get"])
+    def download_report(self, request, pk=None):
+        task = self.get_object()
+        response = HttpResponse(content_type="text/csv")
+        filename = f"cyclecount_{task.doc_no}.csv"
+        response["Content-Disposition"] = f'attachment; filename="{filename}"'
+
+        writer = csv.writer(response)
+        writer.writerow(
+            ["序号", "物料名称", "规格", "单位", "托盘数量", "初盘", "再盘", "盘差数量", "原因及说明", "备注"]
+        )
+        for idx, detail in enumerate(task.details.select_related("reason").all(), start=1):
+            writer.writerow(
+                [
+                    idx,
+                    detail.goods_desc,
+                    detail.goods_std,
+                    detail.goods_unit,
+                    detail.book_qty,
+                    detail.initial_count_qty or "",
+                    detail.recount_qty or "",
+                    detail.variance_qty,
+                    detail.reason.description if detail.reason else "",
+                    detail.note,
+                ]
+            )
+        return response
+
+    @action(detail=True, methods=["post"], url_path="release")
+    def release(self, request, pk=None):
+        task = self.get_object()
+        if task.status not in (CountTask.STATUS_DRAFT, CountTask.STATUS_RELEASED):
+            return Response({"detail": "仅草稿任务可以下发"}, status=status.HTTP_400_BAD_REQUEST)
+
+        details = list(task.details.select_related("container").all())
+        if not details:
+            return Response({"detail": "请先生成任务明细"}, status=status.HTTP_400_BAD_REQUEST)
+
+        batch_containers = defaultdict(list)
+        processed_pairs = set()
+        skipped_messages = []
+
+        for detail in details:
+            container = detail.container
+            if not container:
+                skipped_messages.append(f"明细{detail.id}缺少托盘信息")
+                continue
+
+            batch_obj = None
+            if detail.batch_number:
+                batch_obj = BoundBatchModel.objects.filter(bound_number=detail.batch_number).first()
+
+            if not batch_obj:
+                batch_detail_qs = ContainerDetailModel.objects.filter(
+                    container=container, is_delete=False
+                ).exclude(status=3)
+                if detail.batch_number:
+                    batch_detail_qs = batch_detail_qs.filter(batch__bound_number=detail.batch_number)
+                batch_detail = batch_detail_qs.select_related("batch").first()
+                if batch_detail and batch_detail.batch:
+                    batch_obj = batch_detail.batch
+
+            if not batch_obj:
+                skipped_messages.append(f"托盘{container.container_code}无法匹配批次")
+                continue
+
+            location_link = (
+                LocationContainerLink.objects.filter(container=container, is_active=True)
+                .select_related("location")
+                .first()
+            )
+            if not location_link or not location_link.location:
+                skipped_messages.append(f"托盘{container.container_code}不在有效库位")
+                continue
+
+            pair_key = (container.id, batch_obj.id)
+            if pair_key in processed_pairs:
+                continue
+            processed_pairs.add(pair_key)
+
+            location = location_link.location
+            batch_containers[batch_obj.id].append(
+                {
+                    "container_number": container.id,
+                    "batch_id": batch_obj.id,
+                    "c_number": location.c_number or 0,
+                    "location_code": location.location_code,
+                }
+            )
+
+        if not batch_containers:
+            return Response(
+                {
+                    "detail": "没有可下发的托盘",
+                    "skipped": skipped_messages,
+                },
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        created_batches = []
+        total_containers = 0
+        for batch_id, containers in batch_containers.items():
+            result = OutboundService.create_initial_check_tasks(containers, batch_id)
+            if result is False:
+                skipped_messages.append(f"批次{batch_id}已有正在执行的抽检任务")
+                continue
+            created_batches.append(batch_id)
+            total_containers += len(containers)
+
+        if not created_batches:
+            return Response(
+                {
+                    "detail": "未能创建抽检任务",
+                    "skipped": skipped_messages,
+                },
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        OutboundService.process_next_task()
+        CountTask.objects.filter(pk=task.pk).update(status=CountTask.STATUS_RELEASED)
+        task.refresh_from_db(fields=["status", "update_time"])
+
+        return Response(
+            {
+                "detail": "任务已下发",
+                "released_batches": created_batches,
+                "released_containers": total_containers,
+                "skipped": skipped_messages,
+            },
+            status=status.HTTP_200_OK,
+        )
+
+
+class CountTaskDetailViewSet(viewsets.ModelViewSet):
+    """Task detail endpoint for PDA/Web."""
+
+    queryset = CountTaskDetail.objects.select_related("task", "reason", "container").order_by("id")
+    serializer_class = CountTaskDetailSerializer
+    pagination_class = MyPageNumberPagination
+    filter_backends = [DjangoFilterBackend, OrderingFilter]
+    filterset_fields = ["task", "status", "container_code", "goods_code"]
+    ordering_fields = ["id", "create_time", "update_time", "variance_qty"]
+
+    @action(detail=True, methods=["post"], url_path="count")
+    def submit_count(self, request, pk=None):
+        detail = self.get_object()
+        serializer = CountDetailSubmitSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
+        qty = serializer.validated_data["qty"]
+        round_type = serializer.validated_data["round_type"]
+        reason = serializer.validated_data.get("reason_id")
+        note = serializer.validated_data.get("note")
+        counter_name = serializer.validated_data.get("counter_name")
+
+        if round_type == CountDetailSubmitSerializer.ROUND_INITIAL:
+            detail.initial_count_qty = qty
+            if counter_name:
+                detail.counter_name = counter_name
+            # reset recount when redoing initial count
+            detail.recount_qty = None
+            detail.recount_counter = ""
+        else:
+            detail.recount_qty = qty
+            if counter_name:
+                detail.recount_counter = counter_name
+
+        if reason:
+            detail.reason = reason
+        if note is not None:
+            detail.note = note
+        detail.save()
+
+        output = CountTaskDetailSerializer(detail, context={"request": request})
+        return Response(output.data, status=status.HTTP_200_OK)
+

+ 1 - 0
greaterwms/settings.py

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
     'bin.apps.BinConfig',
     'erp.apps.ErpConfig',
     'operation_log',
+    'cyclecount.apps.CyclecountConfig',
     
     'throttle.apps.ThrottleConfig',
     'rest_framework',

+ 1 - 0
greaterwms/urls.py

@@ -29,6 +29,7 @@ urlpatterns = [
 
     path('bound/', include('bound.urls')),
     path('container/', include('container.urls')),
+    path('cyclecount/', include('cyclecount.urls')),
     path ('wms/', include('erp.urls')),
     path ('backup/', include('backup.urls')),
     path('location_statistics/', include('location_statistics.urls')),

+ 1 - 0
templates/dist/spa/css/12.50853e25.css

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

templates/dist/spa/css/12.c5bafa54.css → templates/dist/spa/css/13.c5bafa54.css


templates/dist/spa/css/13.b352291e.css → templates/dist/spa/css/14.b352291e.css


templates/dist/spa/css/14.84ffb38a.css → templates/dist/spa/css/15.84ffb38a.css


templates/dist/spa/css/15.285d986e.css → templates/dist/spa/css/16.285d986e.css


templates/dist/spa/css/16.80435c82.css → templates/dist/spa/css/17.80435c82.css


templates/dist/spa/css/17.074df1c7.css → templates/dist/spa/css/18.074df1c7.css


templates/dist/spa/css/18.f57b1220.css → templates/dist/spa/css/19.f57b1220.css


templates/dist/spa/css/19.9e7bbbba.css → templates/dist/spa/css/20.9e7bbbba.css


templates/dist/spa/css/20.296f042c.css → templates/dist/spa/css/21.296f042c.css


templates/dist/spa/css/21.8fe0c02a.css → templates/dist/spa/css/22.8fe0c02a.css


templates/dist/spa/css/22.abdf7366.css → templates/dist/spa/css/23.abdf7366.css


templates/dist/spa/css/23.83beb898.css → templates/dist/spa/css/24.83beb898.css


templates/dist/spa/css/24.601677c3.css → templates/dist/spa/css/25.601677c3.css


templates/dist/spa/css/25.6dea747f.css → templates/dist/spa/css/26.6dea747f.css


templates/dist/spa/css/26.f721cf95.css → templates/dist/spa/css/27.f721cf95.css


templates/dist/spa/css/27.ed8e81e9.css → templates/dist/spa/css/28.ed8e81e9.css


templates/dist/spa/css/28.eed22a1c.css → templates/dist/spa/css/29.eed22a1c.css


templates/dist/spa/css/29.2f5a0931.css → templates/dist/spa/css/30.2f5a0931.css


templates/dist/spa/css/30.e6923623.css → templates/dist/spa/css/31.e6923623.css


templates/dist/spa/css/31.01a9029f.css → templates/dist/spa/css/32.01a9029f.css


templates/dist/spa/css/32.9b0c5133.css → templates/dist/spa/css/33.9b0c5133.css


templates/dist/spa/css/33.103b121d.css → templates/dist/spa/css/34.103b121d.css


+ 0 - 1
templates/dist/spa/css/34.6a5a5158.css

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

+ 0 - 1
templates/dist/spa/css/35.56a2e799.css

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

File diff suppressed because it is too large
+ 1 - 0
templates/dist/spa/css/35.da66b954.css


+ 1 - 0
templates/dist/spa/css/36.d96cdb0f.css

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

templates/dist/spa/css/36.8f3f6188.css → templates/dist/spa/css/37.064414c5.css


templates/dist/spa/css/37.44ddcebd.css → templates/dist/spa/css/38.44ddcebd.css


templates/dist/spa/css/38.2ac1dad1.css → templates/dist/spa/css/39.2ac1dad1.css


templates/dist/spa/css/39.12670fd1.css → templates/dist/spa/css/40.12670fd1.css


templates/dist/spa/css/40.9478c981.css → templates/dist/spa/css/41.9478c981.css


templates/dist/spa/css/41.c4652654.css → templates/dist/spa/css/42.c4652654.css


templates/dist/spa/css/42.7a23b7fb.css → templates/dist/spa/css/43.7a23b7fb.css


templates/dist/spa/css/43.07732723.css → templates/dist/spa/css/44.07732723.css


templates/dist/spa/css/44.2594d0b9.css → templates/dist/spa/css/45.2594d0b9.css


templates/dist/spa/css/45.0faa4aeb.css → templates/dist/spa/css/46.0faa4aeb.css


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/index.html


File diff suppressed because it is too large
+ 1 - 0
templates/dist/spa/js/100.90c42c1f.js


File diff suppressed because it is too large
+ 1 - 0
templates/dist/spa/js/12.0e19bf74.js


BIN
templates/dist/spa/js/12.0e19bf74.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/12.19cc6ade.js


BIN
templates/dist/spa/js/12.19cc6ade.js.gz


BIN
templates/dist/spa/js/14.0b3059c5.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/13.eb7c38b1.js


BIN
templates/dist/spa/js/13.eb7c38b1.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/14.0b3059c5.js


BIN
templates/dist/spa/js/15.83475395.js.gz


BIN
templates/dist/spa/js/15.b7dd6c80.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/15.b7dd6c80.js


BIN
templates/dist/spa/js/16.9ff541bd.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/16.2ed466dc.js


BIN
templates/dist/spa/js/16.2ed466dc.js.gz


BIN
templates/dist/spa/js/18.18213a6e.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/17.9115b827.js


BIN
templates/dist/spa/js/17.9115b827.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/18.18213a6e.js


BIN
templates/dist/spa/js/19.8a3a5eae.js.gz


BIN
templates/dist/spa/js/19.b1cca1b7.js.gz


BIN
templates/dist/spa/js/20.4730a8aa.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/19.b1cca1b7.js


BIN
templates/dist/spa/js/20.9b815f26.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/20.4730a8aa.js


BIN
templates/dist/spa/js/21.00122f16.js.gz


BIN
templates/dist/spa/js/21.6eee568e.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/21.6eee568e.js


BIN
templates/dist/spa/js/22.b07fa6f1.js.gz


BIN
templates/dist/spa/js/22.ea906fdf.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/22.ea906fdf.js


BIN
templates/dist/spa/js/23.6b9b1c0b.js.gz


BIN
templates/dist/spa/js/24.31e1a220.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/23.e455edd8.js


BIN
templates/dist/spa/js/23.e455edd8.js.gz


BIN
templates/dist/spa/js/25.91acd9c1.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/24.31e1a220.js


BIN
templates/dist/spa/js/25.d3a90663.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/25.91acd9c1.js


BIN
templates/dist/spa/js/26.04fed0ec.js.gz


File diff suppressed because it is too large
+ 1 - 1
templates/dist/spa/js/26.c2f59029.js


BIN
templates/dist/spa/js/26.c2f59029.js.gz


+ 0 - 0
templates/dist/spa/js/27.c6cfb996.js


Some files were not shown because too many files changed in this diff