|
|
@@ -6,7 +6,8 @@ from datetime import timedelta
|
|
|
from .services import LocationStatisticsService
|
|
|
from .models import LocationGroupStatistics
|
|
|
from django.db import transaction
|
|
|
-from django.db.models import Count, Q
|
|
|
+from django.db.models import Count, Q, Prefetch
|
|
|
+from decimal import Decimal, InvalidOperation
|
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
@@ -145,19 +146,43 @@ class LocationConsistencyCheckView(APIView):
|
|
|
|
|
|
def post(self, request):
|
|
|
warehouse_code = request.data.get('warehouse_code')
|
|
|
- layer = request.GET.get('layer')
|
|
|
- if int(layer) < 1 :
|
|
|
- layer = None
|
|
|
- auto_fix = request.data.get('auto_fix', False)
|
|
|
-
|
|
|
- checker = LocationConsistencyChecker(warehouse_code, layer, auto_fix)
|
|
|
- result = checker.check_all()
|
|
|
+ layer_param = request.GET.get('layer')
|
|
|
+ layer = None
|
|
|
+ if layer_param is not None:
|
|
|
+ try:
|
|
|
+ layer_value = int(layer_param)
|
|
|
+ if layer_value > 0:
|
|
|
+ layer = layer_value
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ layer = None
|
|
|
+ auto_fix = self._to_bool(request.data.get('auto_fix', False))
|
|
|
+ fix_scope = request.data.get('fix_scope')
|
|
|
+
|
|
|
+ checker = LocationConsistencyChecker(
|
|
|
+ warehouse_code,
|
|
|
+ layer,
|
|
|
+ auto_fix,
|
|
|
+ fix_scope=fix_scope
|
|
|
+ )
|
|
|
+ checker.check_all()
|
|
|
|
|
|
return Response({
|
|
|
'success': True,
|
|
|
- 'data': checker.generate_report()
|
|
|
+ 'data': checker.generate_report(),
|
|
|
+ 'auto_fix': auto_fix,
|
|
|
+ 'fix_scope': checker.fix_scope
|
|
|
})
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _to_bool(value):
|
|
|
+ if isinstance(value, bool):
|
|
|
+ return value
|
|
|
+ if isinstance(value, str):
|
|
|
+ return value.strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
|
|
|
+ if isinstance(value, int):
|
|
|
+ return value != 0
|
|
|
+ return False
|
|
|
+
|
|
|
class LocationConsistencyChecker:
|
|
|
TARGET_LOCATION_TYPES = ['T5', 'T4', 'S4', 'T2', 'T1']
|
|
|
"""
|
|
|
@@ -165,7 +190,7 @@ class LocationConsistencyChecker:
|
|
|
用于检测库位状态与Link记录的一致性以及库位组的状态一致性
|
|
|
"""
|
|
|
|
|
|
- def __init__(self, warehouse_code=None, layer=None, auto_fix=False):
|
|
|
+ def __init__(self, warehouse_code=None, layer=None, auto_fix=False, fix_scope=None):
|
|
|
"""
|
|
|
初始化检测器
|
|
|
|
|
|
@@ -173,16 +198,37 @@ class LocationConsistencyChecker:
|
|
|
warehouse_code: 指定仓库代码,如果为None则检测所有仓库
|
|
|
layer: 指定楼层,如果为None则检测所有楼层
|
|
|
auto_fix: 是否自动修复检测到的问题
|
|
|
+ fix_scope: 修复范围(locations/details/groups),None表示全部
|
|
|
"""
|
|
|
self.warehouse_code = warehouse_code
|
|
|
self.layer = layer
|
|
|
+ if self.layer is not None:
|
|
|
+ try:
|
|
|
+ layer_int = int(self.layer)
|
|
|
+ self.layer = layer_int if layer_int > 0 else None
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ self.layer = None
|
|
|
self.auto_fix = auto_fix
|
|
|
+ if isinstance(fix_scope, (list, tuple, set)):
|
|
|
+ fix_scope = [scope for scope in fix_scope if scope in {'locations', 'groups', 'details'}]
|
|
|
+ elif isinstance(fix_scope, str):
|
|
|
+ if fix_scope == 'all':
|
|
|
+ fix_scope = None
|
|
|
+ elif fix_scope in {'locations', 'groups', 'details'}:
|
|
|
+ fix_scope = [fix_scope]
|
|
|
+ else:
|
|
|
+ fix_scope = None
|
|
|
+ else:
|
|
|
+ fix_scope = None
|
|
|
+ self.fix_scope = fix_scope or ['locations', 'groups', 'details']
|
|
|
self.results = {
|
|
|
'check_time': timezone.now(),
|
|
|
'location_errors': [],
|
|
|
'group_errors': [],
|
|
|
+ 'detail_errors': [],
|
|
|
'fixed_locations': [],
|
|
|
'fixed_groups': [],
|
|
|
+ 'fixed_details': [],
|
|
|
'repair_errors': [],
|
|
|
'summary': {
|
|
|
'total_locations': 0,
|
|
|
@@ -191,8 +237,12 @@ class LocationConsistencyChecker:
|
|
|
'total_groups': 0,
|
|
|
'checked_groups': 0,
|
|
|
'error_groups': 0,
|
|
|
+ 'total_details': 0,
|
|
|
+ 'checked_details': 0,
|
|
|
+ 'error_details': 0,
|
|
|
'fixed_location_count': 0,
|
|
|
- 'fixed_group_count': 0
|
|
|
+ 'fixed_group_count': 0,
|
|
|
+ 'fixed_detail_count': 0
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -203,6 +253,9 @@ class LocationConsistencyChecker:
|
|
|
# 检测库位状态与Link记录的一致性
|
|
|
self.check_location_link_consistency()
|
|
|
|
|
|
+ # 检测托盘明细状态一致性
|
|
|
+ self.check_container_detail_status()
|
|
|
+
|
|
|
# 检测库位组状态一致性
|
|
|
self.check_group_consistency()
|
|
|
|
|
|
@@ -211,7 +264,8 @@ class LocationConsistencyChecker:
|
|
|
self.fix_detected_issues()
|
|
|
|
|
|
logger.info(f"库位一致性检测完成,发现{self.results['summary']['error_locations']}个库位问题,"
|
|
|
- f"{self.results['summary']['error_groups']}个库位组问题")
|
|
|
+ f"{self.results['summary']['error_groups']}个库位组问题,"
|
|
|
+ f"{self.results['summary']['error_details']}条托盘明细问题")
|
|
|
|
|
|
return self.results
|
|
|
|
|
|
@@ -282,6 +336,107 @@ class LocationConsistencyChecker:
|
|
|
|
|
|
return True, current_status, None
|
|
|
|
|
|
+ def check_container_detail_status(self):
|
|
|
+ """检测托盘明细状态的一致性"""
|
|
|
+ from container.models import ContainerDetailModel
|
|
|
+ from bin.models import LocationContainerLink
|
|
|
+
|
|
|
+ details_qs = (
|
|
|
+ ContainerDetailModel.objects.filter(is_delete=False)
|
|
|
+ .select_related('container', 'batch')
|
|
|
+ .prefetch_related(
|
|
|
+ Prefetch(
|
|
|
+ 'container__location_links',
|
|
|
+ queryset=LocationContainerLink.objects.filter(is_active=True).select_related('location'),
|
|
|
+ to_attr='active_links'
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ total_checked = 0
|
|
|
+
|
|
|
+ for detail in details_qs:
|
|
|
+ if not detail.container:
|
|
|
+ continue
|
|
|
+
|
|
|
+ if not self._detail_in_scope(detail):
|
|
|
+ continue
|
|
|
+
|
|
|
+ goods_qty = self._to_decimal(detail.goods_qty)
|
|
|
+ goods_out_qty = self._to_decimal(detail.goods_out_qty)
|
|
|
+ remaining_qty = goods_qty - goods_out_qty
|
|
|
+ expected_status, error_type = self._determine_detail_status(remaining_qty)
|
|
|
+
|
|
|
+ total_checked += 1
|
|
|
+ self.results['summary']['checked_details'] += 1
|
|
|
+
|
|
|
+ if expected_status is None:
|
|
|
+ continue
|
|
|
+
|
|
|
+ if detail.status != expected_status:
|
|
|
+ self.results['summary']['error_details'] += 1
|
|
|
+ self.results['detail_errors'].append({
|
|
|
+ 'detail_id': detail.id,
|
|
|
+ 'container_id': detail.container_id,
|
|
|
+ 'container_code': getattr(detail.container, 'container_code', None),
|
|
|
+ 'batch_id': detail.batch_id,
|
|
|
+ 'batch_number': getattr(detail.batch, 'bound_number', None) if detail.batch else None,
|
|
|
+ 'goods_qty': str(goods_qty),
|
|
|
+ 'goods_out_qty': str(goods_out_qty),
|
|
|
+ 'remaining_qty': str(remaining_qty),
|
|
|
+ 'current_status': detail.status,
|
|
|
+ 'current_status_display': self._status_display(detail.status),
|
|
|
+ 'expected_status': expected_status,
|
|
|
+ 'expected_status_display': self._status_display(expected_status),
|
|
|
+ 'error_type': error_type,
|
|
|
+ 'detected_at': timezone.now()
|
|
|
+ })
|
|
|
+
|
|
|
+ self.results['summary']['total_details'] = total_checked
|
|
|
+
|
|
|
+ def _detail_in_scope(self, detail):
|
|
|
+ """判断托盘明细是否在当前检测范围内"""
|
|
|
+ if not (self.warehouse_code or self.layer):
|
|
|
+ return True
|
|
|
+
|
|
|
+ active_links = getattr(detail.container, 'active_links', None)
|
|
|
+ if not active_links:
|
|
|
+ return False
|
|
|
+
|
|
|
+ warehouse_ok = True
|
|
|
+ layer_ok = True
|
|
|
+
|
|
|
+ if self.warehouse_code:
|
|
|
+ warehouse_ok = any(link.location.warehouse_code == self.warehouse_code for link in active_links)
|
|
|
+
|
|
|
+ if self.layer is not None:
|
|
|
+ layer_ok = any(link.location.layer == self.layer for link in active_links)
|
|
|
+
|
|
|
+ return warehouse_ok and layer_ok
|
|
|
+
|
|
|
+ def _to_decimal(self, value):
|
|
|
+ if isinstance(value, Decimal):
|
|
|
+ return value
|
|
|
+ if value is None:
|
|
|
+ return Decimal('0')
|
|
|
+ try:
|
|
|
+ return Decimal(str(value))
|
|
|
+ except (InvalidOperation, TypeError, ValueError):
|
|
|
+ return Decimal('0')
|
|
|
+
|
|
|
+ def _determine_detail_status(self, remaining_qty):
|
|
|
+ if remaining_qty > 0:
|
|
|
+ return 2, 'detail_should_be_in_stock'
|
|
|
+ return 3, 'detail_should_be_outbound'
|
|
|
+
|
|
|
+ def _status_display(self, status):
|
|
|
+ status_map = {
|
|
|
+ 0: '空盘',
|
|
|
+ 2: '在盘',
|
|
|
+ 3: '离库'
|
|
|
+ }
|
|
|
+ return status_map.get(status, str(status) if status is not None else '未知')
|
|
|
+
|
|
|
def check_group_consistency(self):
|
|
|
"""检测库位组状态的一致性"""
|
|
|
from bin.models import LocationGroupModel
|
|
|
@@ -380,15 +535,20 @@ class LocationConsistencyChecker:
|
|
|
logger.info("开始修复检测到的不一致问题")
|
|
|
|
|
|
# 修复库位状态不一致
|
|
|
- if self.results['location_errors']:
|
|
|
+ if 'locations' in self.fix_scope and self.results['location_errors']:
|
|
|
self._fix_location_issues()
|
|
|
|
|
|
+ # 修复托盘明细状态不一致
|
|
|
+ if 'details' in self.fix_scope and self.results['detail_errors']:
|
|
|
+ self._fix_detail_issues()
|
|
|
+
|
|
|
# 修复库位组状态不一致
|
|
|
- if self.results['group_errors']:
|
|
|
+ if 'groups' in self.fix_scope and self.results['group_errors']:
|
|
|
self._fix_group_issues()
|
|
|
|
|
|
logger.info(f"修复完成: {self.results['summary']['fixed_location_count']}个库位, "
|
|
|
- f"{self.results['summary']['fixed_group_count']}个库位组")
|
|
|
+ f"{self.results['summary']['fixed_group_count']}个库位组, "
|
|
|
+ f"{self.results['summary']['fixed_detail_count']}条托盘明细")
|
|
|
|
|
|
def _fix_location_issues(self):
|
|
|
"""修复库位状态不一致问题"""
|
|
|
@@ -478,21 +638,67 @@ class LocationConsistencyChecker:
|
|
|
'error_type': error.get('error_type')
|
|
|
})
|
|
|
|
|
|
+ def _fix_detail_issues(self):
|
|
|
+ """修复托盘明细状态不一致问题"""
|
|
|
+ from container.models import ContainerDetailModel
|
|
|
+
|
|
|
+ for error in self.results['detail_errors']:
|
|
|
+ expected_status = error.get('expected_status')
|
|
|
+ if expected_status not in [2, 3]:
|
|
|
+ continue
|
|
|
+
|
|
|
+ try:
|
|
|
+ with transaction.atomic():
|
|
|
+ detail = ContainerDetailModel.objects.select_for_update().select_related('container').get(id=error['detail_id'])
|
|
|
+ if detail.status == expected_status:
|
|
|
+ continue
|
|
|
+
|
|
|
+ old_status = detail.status
|
|
|
+ detail.status = expected_status
|
|
|
+ detail.save(update_fields=['status'])
|
|
|
+
|
|
|
+ self.results['summary']['fixed_detail_count'] += 1
|
|
|
+ error['fixed'] = True
|
|
|
+ error['fixed_at'] = timezone.now()
|
|
|
+ error['new_status'] = expected_status
|
|
|
+ error['new_status_display'] = self._status_display(expected_status)
|
|
|
+ self.results['fixed_details'].append({
|
|
|
+ 'detail_id': detail.id,
|
|
|
+ 'container_code': getattr(detail.container, 'container_code', None),
|
|
|
+ 'old_status': old_status,
|
|
|
+ 'new_status': expected_status,
|
|
|
+ 'fixed_at': timezone.now(),
|
|
|
+ 'error_type': error.get('error_type')
|
|
|
+ })
|
|
|
+ except ContainerDetailModel.DoesNotExist:
|
|
|
+ logger.warning(f"托盘明细 {error.get('detail_id')} 不存在,跳过修复")
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"修复托盘明细{error.get('detail_id')}失败: {str(e)}")
|
|
|
+ self.results['repair_errors'].append({
|
|
|
+ 'type': 'detail_fix_error',
|
|
|
+ 'detail_id': error.get('detail_id'),
|
|
|
+ 'error_message': str(e),
|
|
|
+ 'error_type': error.get('error_type')
|
|
|
+ })
|
|
|
+
|
|
|
def get_summary(self):
|
|
|
"""获取检测摘要"""
|
|
|
return {
|
|
|
'check_time': self.results['check_time'],
|
|
|
'total_checked': {
|
|
|
'locations': self.results['summary']['checked_locations'],
|
|
|
- 'groups': self.results['summary']['checked_groups']
|
|
|
+ 'groups': self.results['summary']['checked_groups'],
|
|
|
+ 'details': self.results['summary']['checked_details']
|
|
|
},
|
|
|
'errors_found': {
|
|
|
'locations': self.results['summary']['error_locations'],
|
|
|
- 'groups': self.results['summary']['error_groups']
|
|
|
+ 'groups': self.results['summary']['error_groups'],
|
|
|
+ 'details': self.results['summary']['error_details']
|
|
|
},
|
|
|
'fixed': {
|
|
|
'locations': self.results['summary']['fixed_location_count'],
|
|
|
- 'groups': self.results['summary']['fixed_group_count']
|
|
|
+ 'groups': self.results['summary']['fixed_group_count'],
|
|
|
+ 'details': self.results['summary']['fixed_detail_count']
|
|
|
},
|
|
|
'has_errors': len(self.results['repair_errors']) > 0
|
|
|
}
|
|
|
@@ -506,8 +712,10 @@ class LocationConsistencyChecker:
|
|
|
'details': {
|
|
|
'location_errors': self.results['location_errors'],
|
|
|
'group_errors': self.results['group_errors'],
|
|
|
+ 'detail_errors': self.results['detail_errors'],
|
|
|
'fixed_locations': self.results['fixed_locations'],
|
|
|
'fixed_groups': self.results['fixed_groups'],
|
|
|
+ 'fixed_details': self.results['fixed_details'],
|
|
|
'repair_errors': self.results['repair_errors']
|
|
|
}
|
|
|
}
|