views.py 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225
  1. from rest_framework.views import APIView
  2. from rest_framework.response import Response
  3. from rest_framework import status
  4. from django.utils import timezone
  5. from datetime import timedelta
  6. from .services import LocationStatisticsService
  7. from .models import LocationGroupStatistics
  8. from django.db import transaction
  9. from django.db.models import Count, Q, Prefetch
  10. from decimal import Decimal, InvalidOperation
  11. import logging
  12. logger = logging.getLogger(__name__)
  13. from .serializers import LocationStatisticsSerializer, LocationGroupStatisticsSerializer
  14. class LocationStatisticsView(APIView):
  15. """货位统计API视图"""
  16. def get(self, request):
  17. """获取货位统计信息"""
  18. warehouse_code = request.GET.get('warehouse_code')
  19. layer = request.GET.get('layer')
  20. # 尝试从参数转换层数
  21. if layer is not None:
  22. try:
  23. layer = int(layer)
  24. except ValueError:
  25. layer = None
  26. # 从数据库获取最新统计
  27. statistics = LocationStatisticsService.get_latest_statistics(warehouse_code, layer)
  28. serializer = LocationStatisticsSerializer(statistics, many=True)
  29. response_data = {
  30. 'timestamp': timezone.now().isoformat(),
  31. 'data': serializer.data
  32. }
  33. return Response(response_data)
  34. def post(self, request):
  35. """手动触发统计计算"""
  36. warehouse_code = request.data.get('warehouse_code')
  37. layer = request.data.get('layer')
  38. # 计算统计信息
  39. stats_data = LocationStatisticsService.calculate_location_statistics(warehouse_code, layer)
  40. # 保存到数据库
  41. count = LocationStatisticsService.save_statistics_to_db(stats_data)
  42. return Response({
  43. 'message': f'成功统计了{count}条记录',
  44. 'timestamp': timezone.now().isoformat()
  45. }, status=status.HTTP_201_CREATED)
  46. class LocationStatisticsHistoryView(APIView):
  47. """货位统计历史数据API视图"""
  48. def get(self, request):
  49. """获取历史统计信息"""
  50. warehouse_code = request.GET.get('warehouse_code')
  51. layer = request.GET.get('layer')
  52. hours = int(request.GET.get('hours', 24)) # 默认最近24小时
  53. end_time = timezone.now()
  54. start_time = end_time - timedelta(hours=hours)
  55. statistics = LocationStatisticsService.get_statistics_by_time_range(
  56. start_time, end_time, warehouse_code, layer
  57. )
  58. serializer = LocationStatisticsSerializer(statistics, many=True)
  59. return Response({
  60. 'period': f'{start_time} - {end_time}',
  61. 'data': serializer.data
  62. })
  63. class LocationGroupStatisticsView(APIView):
  64. """货位组统计API视图"""
  65. def get(self, request):
  66. """获取货位组统计信息"""
  67. warehouse_code = request.GET.get('warehouse_code')
  68. layer = request.GET.get('layer')
  69. # 新增过滤参数
  70. min_utilization = float(request.GET.get('min_utilization', 0)) # 最小使用率,默认0
  71. min_used_locations = int(request.GET.get('min_used_locations', 0)) # 最小已用货位数,默认0
  72. filters = {}
  73. if warehouse_code:
  74. filters['warehouse_code'] = warehouse_code
  75. if layer:
  76. try:
  77. filters['layer'] = int(layer)
  78. except ValueError:
  79. pass
  80. # 获取最新的组统计
  81. from django.db.models import Max
  82. latest_stats = LocationGroupStatistics.objects.filter(
  83. **filters
  84. ).values('warehouse_code', 'layer', 'location_group').annotate(
  85. latest_time=Max('statistic_time')
  86. )
  87. group_stats = []
  88. for stat in latest_stats:
  89. latest_record = LocationGroupStatistics.objects.filter(
  90. warehouse_code=stat['warehouse_code'],
  91. layer=stat['layer'],
  92. location_group=stat['location_group'],
  93. statistic_time=stat['latest_time']
  94. ).first()
  95. # 根据参数过滤
  96. if latest_record:
  97. # 检查使用率条件
  98. utilization_ok = latest_record.utilization_rate >= min_utilization
  99. # 检查已用货位数条件
  100. used_locations_ok = latest_record.used_locations >= min_used_locations
  101. if utilization_ok and used_locations_ok:
  102. group_stats.append(latest_record)
  103. serializer = LocationGroupStatisticsSerializer(group_stats, many=True)
  104. return Response({
  105. 'timestamp': timezone.now().isoformat(),
  106. 'filters': {
  107. 'min_utilization': min_utilization,
  108. 'min_used_locations': min_used_locations,
  109. 'total_records': len(group_stats)
  110. },
  111. 'data': serializer.data
  112. })
  113. class LocationConsistencyCheckView(APIView):
  114. """库位一致性检查API"""
  115. def post(self, request):
  116. warehouse_code = request.data.get('warehouse_code')
  117. layer_param = request.GET.get('layer')
  118. layer = None
  119. if layer_param is not None:
  120. try:
  121. layer_value = int(layer_param)
  122. if layer_value > 0:
  123. layer = layer_value
  124. except (TypeError, ValueError):
  125. layer = None
  126. auto_fix = self._to_bool(request.data.get('auto_fix', False))
  127. fix_scope = request.data.get('fix_scope')
  128. checker = LocationConsistencyChecker(
  129. warehouse_code,
  130. layer,
  131. auto_fix,
  132. fix_scope=fix_scope
  133. )
  134. checker.check_all()
  135. return Response({
  136. 'success': True,
  137. 'data': checker.generate_report(),
  138. 'auto_fix': auto_fix,
  139. 'fix_scope': checker.fix_scope
  140. })
  141. @staticmethod
  142. def _to_bool(value):
  143. if isinstance(value, bool):
  144. return value
  145. if isinstance(value, str):
  146. return value.strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
  147. if isinstance(value, int):
  148. return value != 0
  149. return False
  150. class LocationConsistencyChecker:
  151. TARGET_LOCATION_TYPES = ['T5', 'T4', 'S4', 'T2', 'T1']
  152. """
  153. 库位一致性检测器
  154. 用于检测库位状态与Link记录的一致性以及库位组的状态一致性
  155. """
  156. def __init__(self, warehouse_code=None, layer=None, auto_fix=False, fix_scope=None):
  157. """
  158. 初始化检测器
  159. Args:
  160. warehouse_code: 指定仓库代码,如果为None则检测所有仓库
  161. layer: 指定楼层,如果为None则检测所有楼层
  162. auto_fix: 是否自动修复检测到的问题
  163. fix_scope: 修复范围(locations/details/groups),None表示全部
  164. """
  165. self.warehouse_code = warehouse_code
  166. self.layer = layer
  167. if self.layer is not None:
  168. try:
  169. layer_int = int(self.layer)
  170. self.layer = layer_int if layer_int > 0 else None
  171. except (TypeError, ValueError):
  172. self.layer = None
  173. self.auto_fix = auto_fix
  174. if isinstance(fix_scope, (list, tuple, set)):
  175. fix_scope = [scope for scope in fix_scope if scope in {'locations', 'groups', 'details'}]
  176. elif isinstance(fix_scope, str):
  177. if fix_scope == 'all':
  178. fix_scope = None
  179. elif fix_scope in {'locations', 'groups', 'details'}:
  180. fix_scope = [fix_scope]
  181. else:
  182. fix_scope = None
  183. else:
  184. fix_scope = None
  185. self.fix_scope = fix_scope or ['locations', 'groups', 'details']
  186. self.results = {
  187. 'check_time': timezone.now(),
  188. 'location_errors': [],
  189. 'group_errors': [],
  190. 'detail_errors': [],
  191. 'fixed_locations': [],
  192. 'fixed_groups': [],
  193. 'fixed_details': [],
  194. 'repair_errors': [],
  195. 'summary': {
  196. 'total_locations': 0,
  197. 'checked_locations': 0,
  198. 'error_locations': 0,
  199. 'total_groups': 0,
  200. 'checked_groups': 0,
  201. 'error_groups': 0,
  202. 'total_details': 0,
  203. 'checked_details': 0,
  204. 'error_details': 0,
  205. 'fixed_location_count': 0,
  206. 'fixed_group_count': 0,
  207. 'fixed_detail_count': 0
  208. }
  209. }
  210. def check_all(self):
  211. """执行所有检测"""
  212. logger.info(f"开始库位一致性检测,仓库: {self.warehouse_code}, 楼层: {self.layer}")
  213. # 检测库位状态与Link记录的一致性
  214. self.check_location_link_consistency()
  215. # 检测托盘明细状态一致性
  216. self.check_container_detail_status()
  217. # 检测库位组状态一致性
  218. self.check_group_consistency()
  219. # 如果启用自动修复,执行修复
  220. if self.auto_fix:
  221. self.fix_detected_issues()
  222. logger.info(f"库位一致性检测完成,发现{self.results['summary']['error_locations']}个库位问题,"
  223. f"{self.results['summary']['error_groups']}个库位组问题,"
  224. f"{self.results['summary']['error_details']}条托盘明细问题")
  225. return self.results
  226. def check_location_link_consistency(self):
  227. """检测库位状态与Link记录的一致性,以及托盘当前位置与库位状态的一致性"""
  228. from bin.models import LocationModel, LocationContainerLink
  229. from container.models import ContainerListModel
  230. # 构建查询条件
  231. filters = {'is_active': True, 'location_type__in': self.TARGET_LOCATION_TYPES}
  232. if self.warehouse_code:
  233. filters['warehouse_code'] = self.warehouse_code
  234. if self.layer is not None:
  235. filters['layer'] = self.layer
  236. # 获取库位并预取Link记录
  237. locations = LocationModel.objects.filter(**filters).prefetch_related(
  238. 'container_links'
  239. ).select_related()
  240. self.results['summary']['total_locations'] = locations.count()
  241. for location in locations:
  242. self.results['summary']['checked_locations'] += 1
  243. # 获取该库位的活跃Link记录
  244. active_links = location.container_links.filter(is_active=True).select_related('container')
  245. active_links_count = active_links.count()
  246. # 1. 检查库位状态与Link记录的一致性
  247. is_consistent, expected_status, error_type = self._check_location_consistency(
  248. location.status, active_links_count
  249. )
  250. if not is_consistent:
  251. self.results['summary']['error_locations'] += 1
  252. self.results['location_errors'].append({
  253. 'location_id': location.id,
  254. 'location_code': location.location_code,
  255. 'warehouse_code': location.warehouse_code,
  256. 'current_status': location.status,
  257. 'expected_status': expected_status,
  258. 'active_links_count': active_links_count,
  259. 'layer': location.layer,
  260. 'row': location.row,
  261. 'col': location.col,
  262. 'location_group': location.location_group,
  263. 'error_type': error_type,
  264. 'detected_at': timezone.now()
  265. })
  266. # 2. 检查托盘显示的当前位置与库位状态的一致性
  267. # 查找所有显示当前位置为该库位的托盘
  268. location_coordinate = self._get_location_coordinate(location)
  269. containers_at_location = ContainerListModel.objects.filter(
  270. current_location=location_coordinate,
  271. available=True # ContainerListModel 使用 available 字段而不是 is_delete
  272. )
  273. for container in containers_at_location:
  274. # 检查库位状态是否应该为占用
  275. if location.status == 'available' and active_links_count == 0:
  276. # 库位是空闲的,没有link,但托盘显示当前位置是该库位 - 不一致
  277. self.results['summary']['error_locations'] += 1
  278. self.results['location_errors'].append({
  279. 'location_id': location.id,
  280. 'location_code': location.location_code,
  281. 'warehouse_code': location.warehouse_code,
  282. 'current_status': location.status,
  283. 'expected_status': 'occupied',
  284. 'active_links_count': active_links_count,
  285. 'layer': location.layer,
  286. 'row': location.row,
  287. 'col': location.col,
  288. 'location_group': location.location_group,
  289. 'error_type': 'container_at_location_but_not_bound',
  290. 'container_code': container.container_code,
  291. 'container_current_location': container.current_location,
  292. 'detected_at': timezone.now()
  293. })
  294. # 检查是否有对应的Link记录
  295. container_has_link = active_links.filter(
  296. container__container_code=container.container_code
  297. ).exists()
  298. if not container_has_link and location.status in ['occupied', 'reserved']:
  299. # 托盘显示在该库位,但Link记录中没有关联 - 不一致
  300. self.results['summary']['error_locations'] += 1
  301. self.results['location_errors'].append({
  302. 'location_id': location.id,
  303. 'location_code': location.location_code,
  304. 'warehouse_code': location.warehouse_code,
  305. 'current_status': location.status,
  306. 'expected_status': location.status,
  307. 'active_links_count': active_links_count,
  308. 'layer': location.layer,
  309. 'row': location.row,
  310. 'col': location.col,
  311. 'location_group': location.location_group,
  312. 'error_type': 'container_at_location_without_link',
  313. 'container_code': container.container_code,
  314. 'container_current_location': container.current_location,
  315. 'detected_at': timezone.now()
  316. })
  317. # 3. 检查Link记录中的托盘是否与托盘当前位置一致
  318. for link in active_links:
  319. container = link.container
  320. if container:
  321. container_location_coordinate = self._get_location_coordinate_from_code(
  322. container.current_location
  323. )
  324. location_coordinate_str = self._format_location_coordinate(
  325. location.warehouse_code, location.row, location.col, location.layer
  326. )
  327. # 如果Link记录显示托盘在该库位,但托盘的当前位置不匹配
  328. if container.current_location and container.current_location != location_coordinate_str:
  329. # 检查是否是同一个位置(可能格式不同)
  330. if container_location_coordinate != location_coordinate_str:
  331. self.results['summary']['error_locations'] += 1
  332. self.results['location_errors'].append({
  333. 'location_id': location.id,
  334. 'location_code': location.location_code,
  335. 'warehouse_code': location.warehouse_code,
  336. 'current_status': location.status,
  337. 'expected_status': location.status,
  338. 'active_links_count': active_links_count,
  339. 'layer': location.layer,
  340. 'row': location.row,
  341. 'col': location.col,
  342. 'location_group': location.location_group,
  343. 'error_type': 'link_container_location_mismatch',
  344. 'container_code': container.container_code,
  345. 'container_current_location': container.current_location,
  346. 'expected_container_location': location_coordinate_str,
  347. 'detected_at': timezone.now()
  348. })
  349. def _get_location_coordinate(self, location):
  350. """获取库位的坐标字符串格式(与托盘current_location格式一致)"""
  351. return self._format_location_coordinate(
  352. location.warehouse_code, location.row, location.col, location.layer
  353. )
  354. def _format_location_coordinate(self, warehouse_code, row, col, layer):
  355. """格式化库位坐标为字符串"""
  356. try:
  357. return f"{warehouse_code}-{int(row):02d}-{int(col):02d}-{int(layer):02d}"
  358. except (ValueError, TypeError):
  359. return f"{warehouse_code}-{row}-{col}-{layer}"
  360. def _get_location_coordinate_from_code(self, location_str):
  361. """从库位编码字符串提取坐标信息(用于比较)"""
  362. if not location_str:
  363. return None
  364. try:
  365. # 假设格式为 "W01-01-01-01" 或类似
  366. parts = str(location_str).split('-')
  367. if len(parts) >= 4:
  368. # 标准化格式以便比较
  369. return f"{parts[0]}-{int(parts[1]):02d}-{int(parts[2]):02d}-{int(parts[3]):02d}"
  370. except (ValueError, TypeError, IndexError):
  371. pass
  372. return location_str
  373. def _check_location_consistency(self, current_status, active_links_count):
  374. """
  375. 检查单个库位的状态一致性
  376. Returns:
  377. tuple: (是否一致, 预期状态, 错误类型)
  378. """
  379. if current_status == 'available':
  380. # available状态应该没有活跃的Link记录
  381. if active_links_count > 0:
  382. return False, 'occupied', 'available_but_has_links'
  383. elif current_status in ['occupied','reserved']:
  384. # occupied状态应该至少有一个活跃的Link记录
  385. if active_links_count != 1:
  386. return False, 'available', 'occupied_but_no_links'
  387. elif current_status in ['disabled', 'maintenance']:
  388. # 这些特殊状态可以有Link记录,但通常不应该有
  389. if active_links_count > 0:
  390. return False, current_status, 'special_status_has_links'
  391. return True, current_status, None
  392. def check_container_detail_status(self):
  393. """检测托盘明细状态的一致性"""
  394. from container.models import ContainerDetailModel
  395. from bin.models import LocationContainerLink
  396. details_qs = (
  397. ContainerDetailModel.objects.filter(is_delete=False)
  398. .select_related('container', 'batch')
  399. .prefetch_related(
  400. Prefetch(
  401. 'container__location_links',
  402. queryset=LocationContainerLink.objects.filter(is_active=True).select_related('location'),
  403. to_attr='active_links'
  404. )
  405. )
  406. )
  407. total_checked = 0
  408. for detail in details_qs:
  409. if not detail.container:
  410. continue
  411. if not self._detail_in_scope(detail):
  412. continue
  413. goods_qty = self._to_decimal(detail.goods_qty)
  414. goods_out_qty = self._to_decimal(detail.goods_out_qty)
  415. remaining_qty = goods_qty - goods_out_qty
  416. expected_status, error_type = self._determine_detail_status(remaining_qty)
  417. total_checked += 1
  418. self.results['summary']['checked_details'] += 1
  419. if expected_status is None:
  420. continue
  421. if detail.status != expected_status:
  422. self.results['summary']['error_details'] += 1
  423. self.results['detail_errors'].append({
  424. 'detail_id': detail.id,
  425. 'container_id': detail.container_id,
  426. 'container_code': getattr(detail.container, 'container_code', None),
  427. 'batch_id': detail.batch_id,
  428. 'batch_number': getattr(detail.batch, 'bound_number', None) if detail.batch else None,
  429. 'goods_qty': str(goods_qty),
  430. 'goods_out_qty': str(goods_out_qty),
  431. 'remaining_qty': str(remaining_qty),
  432. 'current_status': detail.status,
  433. 'current_status_display': self._status_display(detail.status),
  434. 'expected_status': expected_status,
  435. 'expected_status_display': self._status_display(expected_status),
  436. 'error_type': error_type,
  437. 'detected_at': timezone.now()
  438. })
  439. self.results['summary']['total_details'] = total_checked
  440. def _detail_in_scope(self, detail):
  441. """判断托盘明细是否在当前检测范围内"""
  442. if not (self.warehouse_code or self.layer):
  443. return True
  444. active_links = getattr(detail.container, 'active_links', None)
  445. if not active_links:
  446. return False
  447. warehouse_ok = True
  448. layer_ok = True
  449. if self.warehouse_code:
  450. warehouse_ok = any(link.location.warehouse_code == self.warehouse_code for link in active_links)
  451. if self.layer is not None:
  452. layer_ok = any(link.location.layer == self.layer for link in active_links)
  453. return warehouse_ok and layer_ok
  454. def _to_decimal(self, value):
  455. if isinstance(value, Decimal):
  456. return value
  457. if value is None:
  458. return Decimal('0')
  459. try:
  460. return Decimal(str(value))
  461. except (InvalidOperation, TypeError, ValueError):
  462. return Decimal('0')
  463. def _determine_detail_status(self, remaining_qty):
  464. if remaining_qty > 0:
  465. return 2, 'detail_should_be_in_stock'
  466. return 3, 'detail_should_be_outbound'
  467. def _status_display(self, status):
  468. status_map = {
  469. 0: '空盘',
  470. 2: '在盘',
  471. 3: '离库'
  472. }
  473. return status_map.get(status, str(status) if status is not None else '未知')
  474. def check_group_consistency(self):
  475. """检测库位组状态的一致性"""
  476. from bin.models import LocationGroupModel
  477. # 构建查询条件
  478. filters = {'is_active': True}
  479. if self.warehouse_code:
  480. filters['warehouse_code'] = self.warehouse_code
  481. if self.layer is not None:
  482. filters['layer'] = self.layer
  483. # 获取库位组并预取关联的库位
  484. groups = LocationGroupModel.objects.filter(**filters).prefetch_related('location_items')
  485. self.results['summary']['total_groups'] = groups.count()
  486. for group in groups:
  487. self.results['summary']['checked_groups'] += 1
  488. # 获取组内所有库位
  489. group_locations = group.location_items.filter(is_active=True)
  490. # 统计组内库位的状态分布
  491. status_counts = self._get_group_status_distribution(group_locations)
  492. total_locations = group_locations.count()
  493. # 根据组内库位状态推断组应该的状态
  494. is_consistent, expected_status, error_type = self._check_group_consistency(
  495. group.status, status_counts, total_locations
  496. )
  497. if not is_consistent:
  498. self.results['summary']['error_groups'] += 1
  499. self.results['group_errors'].append({
  500. 'group_id': group.id,
  501. 'group_code': group.group_code,
  502. 'group_name': group.group_name,
  503. 'warehouse_code': group.warehouse_code,
  504. 'current_status': group.status,
  505. 'expected_status': expected_status,
  506. 'total_locations': total_locations,
  507. 'status_distribution': status_counts,
  508. 'layer': group.layer,
  509. 'error_type': error_type,
  510. 'detected_at': timezone.now()
  511. })
  512. def _get_group_status_distribution(self, group_locations):
  513. """获取组内库位状态分布"""
  514. return group_locations.aggregate(
  515. available_count=Count('id', filter=Q(status='available')),
  516. occupied_count=Count('id', filter=Q(status='occupied')),
  517. disabled_count=Count('id', filter=Q(status='disabled')),
  518. reserved_count=Count('id', filter=Q(status='reserved')),
  519. maintenance_count=Count('id', filter=Q(status='maintenance'))
  520. )
  521. def _check_group_consistency(self, current_status, status_counts, total_locations):
  522. """
  523. 检查库位组状态一致性
  524. Returns:
  525. tuple: (是否一致, 预期状态, 错误类型)
  526. """
  527. if total_locations == 0:
  528. # 空组应该是available或disabled
  529. if current_status not in ['available', 'disabled']:
  530. return False, 'available', 'empty_group_wrong_status'
  531. else:
  532. # 根据库位状态分布推断组状态
  533. if status_counts['occupied_count'] == total_locations:
  534. # 所有库位都被占用,组状态应该是full
  535. if current_status != 'full':
  536. return False, 'full', 'all_occupied_but_not_full'
  537. elif status_counts['occupied_count'] > 0:
  538. # 有库位被占用,组状态应该是occupied
  539. if current_status != 'occupied':
  540. return False, 'occupied', 'has_occupied_but_wrong_status'
  541. elif status_counts['available_count'] == total_locations:
  542. # 所有库位都可用,组状态应该是available
  543. if current_status != 'available':
  544. return False, 'available', 'all_available_but_wrong_status'
  545. # 检查特殊状态
  546. elif status_counts['disabled_count'] > 0 and current_status != 'disabled':
  547. return False, 'disabled', 'has_disabled_but_wrong_status'
  548. elif status_counts['maintenance_count'] > 0 and current_status != 'maintenance':
  549. return False, 'maintenance', 'has_maintenance_but_wrong_status'
  550. return True, current_status, None
  551. def fix_detected_issues(self):
  552. """修复检测到的不一致问题"""
  553. logger.info("开始修复检测到的不一致问题")
  554. # 修复库位状态不一致
  555. if 'locations' in self.fix_scope and self.results['location_errors']:
  556. self._fix_location_issues()
  557. # 修复托盘明细状态不一致
  558. if 'details' in self.fix_scope and self.results['detail_errors']:
  559. self._fix_detail_issues()
  560. # 修复库位组状态不一致
  561. if 'groups' in self.fix_scope and self.results['group_errors']:
  562. self._fix_group_issues()
  563. logger.info(f"修复完成: {self.results['summary']['fixed_location_count']}个库位, "
  564. f"{self.results['summary']['fixed_group_count']}个库位组, "
  565. f"{self.results['summary']['fixed_detail_count']}条托盘明细")
  566. def _fix_location_issues(self):
  567. """修复库位状态不一致问题"""
  568. from bin.models import LocationModel
  569. for error in self.results['location_errors']:
  570. try:
  571. with transaction.atomic():
  572. location = LocationModel.objects.select_for_update().get(id=error['location_id'])
  573. # 只有明确有预期状态时才修复
  574. if error['expected_status'] and error['expected_status'] != 'need_check':
  575. old_status = location.status
  576. location.status = error['expected_status']
  577. location.save()
  578. self.results['summary']['fixed_location_count'] += 1
  579. self.results['fixed_locations'].append({
  580. 'location_id': location.id,
  581. 'location_code': location.location_code,
  582. 'old_status': old_status,
  583. 'new_status': location.status,
  584. 'fixed_at': timezone.now(),
  585. 'error_type': error['error_type']
  586. })
  587. else:
  588. # 标记为需要手动检查
  589. self.results['fixed_locations'].append({
  590. 'location_id': location.id,
  591. 'location_code': location.location_code,
  592. 'status': '需要手动检查',
  593. 'reason': '无法自动确定正确状态',
  594. 'error_type': error['error_type']
  595. })
  596. except Exception as e:
  597. logger.error(f"修复库位{error.get('location_id')}时出错: {str(e)}")
  598. self.results['repair_errors'].append({
  599. 'type': 'location_fix_error',
  600. 'location_id': error.get('location_id'),
  601. 'error_message': str(e),
  602. 'error_type': error.get('error_type')
  603. })
  604. def _fix_group_issues(self):
  605. """修复库位组状态不一致问题"""
  606. from bin.models import LocationGroupModel
  607. for error in self.results['group_errors']:
  608. try:
  609. with transaction.atomic():
  610. group = LocationGroupModel.objects.select_for_update().get(id=error['group_id'])
  611. # 只有明确有预期状态时才修复
  612. if error['expected_status'] and error['expected_status'] != 'need_check':
  613. old_status = group.status
  614. group.status = error['expected_status']
  615. group.save()
  616. self.results['summary']['fixed_group_count'] += 1
  617. self.results['fixed_groups'].append({
  618. 'group_id': group.id,
  619. 'group_code': group.group_code,
  620. 'old_status': old_status,
  621. 'new_status': group.status,
  622. 'fixed_at': timezone.now(),
  623. 'error_type': error['error_type']
  624. })
  625. else:
  626. # 标记为需要手动检查
  627. self.results['fixed_groups'].append({
  628. 'group_id': group.id,
  629. 'group_code': group.group_code,
  630. 'status': '需要手动检查',
  631. 'reason': '无法自动确定正确状态',
  632. 'error_type': error.get('error_type')
  633. })
  634. except Exception as e:
  635. logger.error(f"修复库位组{error.get('group_id')}时出错: {str(e)}")
  636. self.results['repair_errors'].append({
  637. 'type': 'group_fix_error',
  638. 'group_id': error.get('group_id'),
  639. 'error_message': str(e),
  640. 'error_type': error.get('error_type')
  641. })
  642. def _fix_detail_issues(self):
  643. """修复托盘明细状态不一致问题"""
  644. from container.models import ContainerDetailModel
  645. for error in self.results['detail_errors']:
  646. expected_status = error.get('expected_status')
  647. if expected_status not in [2, 3]:
  648. continue
  649. try:
  650. with transaction.atomic():
  651. detail = ContainerDetailModel.objects.select_for_update().select_related('container').get(id=error['detail_id'])
  652. if detail.status == expected_status:
  653. continue
  654. old_status = detail.status
  655. detail.status = expected_status
  656. detail.save(update_fields=['status'])
  657. self.results['summary']['fixed_detail_count'] += 1
  658. error['fixed'] = True
  659. error['fixed_at'] = timezone.now()
  660. error['new_status'] = expected_status
  661. error['new_status_display'] = self._status_display(expected_status)
  662. self.results['fixed_details'].append({
  663. 'detail_id': detail.id,
  664. 'container_code': getattr(detail.container, 'container_code', None),
  665. 'old_status': old_status,
  666. 'new_status': expected_status,
  667. 'fixed_at': timezone.now(),
  668. 'error_type': error.get('error_type')
  669. })
  670. except ContainerDetailModel.DoesNotExist:
  671. logger.warning(f"托盘明细 {error.get('detail_id')} 不存在,跳过修复")
  672. except Exception as e:
  673. logger.error(f"修复托盘明细{error.get('detail_id')}失败: {str(e)}")
  674. self.results['repair_errors'].append({
  675. 'type': 'detail_fix_error',
  676. 'detail_id': error.get('detail_id'),
  677. 'error_message': str(e),
  678. 'error_type': error.get('error_type')
  679. })
  680. def get_summary(self):
  681. """获取检测摘要"""
  682. return {
  683. 'check_time': self.results['check_time'],
  684. 'total_checked': {
  685. 'locations': self.results['summary']['checked_locations'],
  686. 'groups': self.results['summary']['checked_groups'],
  687. 'details': self.results['summary']['checked_details']
  688. },
  689. 'errors_found': {
  690. 'locations': self.results['summary']['error_locations'],
  691. 'groups': self.results['summary']['error_groups'],
  692. 'details': self.results['summary']['error_details']
  693. },
  694. 'fixed': {
  695. 'locations': self.results['summary']['fixed_location_count'],
  696. 'groups': self.results['summary']['fixed_group_count'],
  697. 'details': self.results['summary']['fixed_detail_count']
  698. },
  699. 'has_errors': len(self.results['repair_errors']) > 0
  700. }
  701. def generate_report(self):
  702. """生成检测报告"""
  703. summary = self.get_summary()
  704. report = {
  705. 'summary': summary,
  706. 'details': {
  707. 'location_errors': self.results['location_errors'],
  708. 'group_errors': self.results['group_errors'],
  709. 'detail_errors': self.results['detail_errors'],
  710. 'fixed_locations': self.results['fixed_locations'],
  711. 'fixed_groups': self.results['fixed_groups'],
  712. 'fixed_details': self.results['fixed_details'],
  713. 'repair_errors': self.results['repair_errors']
  714. }
  715. }
  716. return report
  717. # 便捷函数
  718. def check_location_consistency(warehouse_code=None, layer=None, auto_fix=False):
  719. """
  720. 便捷函数:检查库位一致性
  721. Args:
  722. warehouse_code: 仓库代码
  723. layer: 楼层
  724. auto_fix: 是否自动修复
  725. Returns:
  726. dict: 检测结果
  727. """
  728. checker = LocationConsistencyChecker(warehouse_code, layer, auto_fix)
  729. return checker.check_all()
  730. def get_consistency_report(warehouse_code=None, layer=None, auto_fix=False):
  731. """
  732. 获取详细的检测报告
  733. Args:
  734. warehouse_code: 仓库代码
  735. layer: 楼层
  736. auto_fix: 是否自动修复
  737. Returns:
  738. dict: 检测报告
  739. """
  740. checker = LocationConsistencyChecker(warehouse_code, layer, auto_fix)
  741. checker.check_all()
  742. return checker.generate_report()
  743. class BindContainerView(APIView):
  744. """重新绑定托盘到库位"""
  745. def _format_location_coordinate(self, warehouse_code, row, col, layer):
  746. """格式化库位坐标为字符串"""
  747. try:
  748. return f"{warehouse_code}-{int(row):02d}-{int(col):02d}-{int(layer):02d}"
  749. except (ValueError, TypeError):
  750. return f"{warehouse_code}-{row}-{col}-{layer}"
  751. def post(self, request):
  752. location_code = request.data.get('location_code')
  753. container_code = request.data.get('container_code')
  754. if not location_code:
  755. return Response({
  756. 'success': False,
  757. 'message': '缺少库位编码参数'
  758. }, status=status.HTTP_400_BAD_REQUEST)
  759. if not container_code:
  760. return Response({
  761. 'success': False,
  762. 'message': '缺少托盘编码参数'
  763. }, status=status.HTTP_400_BAD_REQUEST)
  764. try:
  765. from bin.models import LocationModel, LocationContainerLink
  766. from container.models import ContainerListModel
  767. from bin.views import LocationAllocation
  768. if len(location_code.split('-')) == 4:
  769. location_row = location_code.split('-')[1]
  770. location_col = location_code.split('-')[2]
  771. location_layer = location_code.split('-')[3]
  772. location = LocationModel.objects.filter(
  773. row=location_row,
  774. col=location_col,
  775. layer=location_layer,
  776. is_active=True).first()
  777. else:
  778. location = LocationModel.objects.filter(
  779. location_code=location_code,
  780. is_active=True
  781. ).first()
  782. if not location:
  783. return Response({
  784. 'success': False,
  785. 'message': f'库位 {location_code} 不存在或已禁用'
  786. }, status=status.HTTP_404_NOT_FOUND)
  787. # 验证托盘是否存在
  788. container = ContainerListModel.objects.filter(
  789. container_code=container_code
  790. ).first()
  791. if not container:
  792. return Response({
  793. 'success': False,
  794. 'message': f'托盘 {container_code} 不存在'
  795. }, status=status.HTTP_404_NOT_FOUND)
  796. # 使用 LocationAllocation 的方法来更新关联
  797. allocator = LocationAllocation()
  798. # 先解除该库位的所有现有关联
  799. LocationContainerLink.objects.filter(
  800. location=location,
  801. is_active=True
  802. ).update(is_active=False)
  803. # 格式化库位坐标为字符串格式(与托盘current_location格式一致)
  804. location_coordinate = self._format_location_coordinate(
  805. location.warehouse_code, location.row, location.col, location.layer
  806. )
  807. # 创建新的关联
  808. with transaction.atomic():
  809. # 检查是否已存在关联(即使是非活跃的)
  810. existing_link = LocationContainerLink.objects.filter(
  811. location=location,
  812. container=container
  813. ).first()
  814. if existing_link:
  815. # 如果存在,重新激活
  816. existing_link.is_active = True
  817. existing_link.save()
  818. else:
  819. # 创建新关联
  820. LocationContainerLink.objects.create(
  821. location=location,
  822. container=container,
  823. is_active=True,
  824. operator=request.auth.name if request.auth else None,
  825. )
  826. # 更新库位状态为占用
  827. location.status = 'occupied'
  828. location.save()
  829. # 更新托盘的current_location和target_location到该库位
  830. container.current_location = location_coordinate
  831. container.target_location = location_coordinate
  832. container.save(update_fields=['current_location', 'target_location'])
  833. # 更新库位组状态
  834. allocator.update_location_group_status(location_code)
  835. logger.info(f"成功重新绑定托盘 {container_code} 到库位 {location_code}")
  836. return Response({
  837. 'success': True,
  838. 'code': '200',
  839. 'message': '重新绑定托盘成功',
  840. 'data': {
  841. 'location_code': location_code,
  842. 'container_code': container_code,
  843. 'status': 'occupied'
  844. }
  845. }, status=status.HTTP_200_OK)
  846. except Exception as e:
  847. logger.error(f"重新绑定托盘失败: {str(e)}", exc_info=True)
  848. return Response({
  849. 'success': False,
  850. 'message': f'重新绑定托盘失败: {str(e)}'
  851. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  852. class UpdateLocationStatusView(APIView):
  853. """更新库位状态"""
  854. def post(self, request):
  855. location_code = request.data.get('location_code')
  856. location_status = request.data.get('status') # 重命名变量避免与 status 模块冲突
  857. if not location_code:
  858. return Response({
  859. 'success': False,
  860. 'message': '缺少库位编码参数'
  861. }, status=status.HTTP_400_BAD_REQUEST)
  862. if not location_status:
  863. return Response({
  864. 'success': False,
  865. 'message': '缺少状态参数'
  866. }, status=status.HTTP_400_BAD_REQUEST)
  867. # 验证状态值
  868. valid_statuses = ['available', 'occupied', 'disabled', 'reserved', 'maintenance']
  869. if location_status not in valid_statuses:
  870. return Response({
  871. 'success': False,
  872. 'message': f'无效的状态值,允许的值: {", ".join(valid_statuses)}'
  873. }, status=status.HTTP_400_BAD_REQUEST)
  874. try:
  875. from bin.models import LocationModel
  876. from bin.views import LocationAllocation
  877. # 验证库位是否存在
  878. location = LocationModel.objects.filter(
  879. location_code=location_code,
  880. is_active=True
  881. ).first()
  882. if not location:
  883. return Response({
  884. 'success': False,
  885. 'message': f'库位 {location_code} 不存在或已禁用'
  886. }, status=status.HTTP_404_NOT_FOUND)
  887. # 保存旧状态
  888. old_status = location.status
  889. # 使用 LocationAllocation 的方法来更新状态
  890. allocator = LocationAllocation()
  891. result = allocator.update_location_status(location_code, location_status, request=request)
  892. if result:
  893. logger.info(f"成功更新库位 {location_code} 状态为 {location_status}")
  894. return Response({
  895. 'success': True,
  896. 'code': '200',
  897. 'message': '更新库位状态成功',
  898. 'data': {
  899. 'location_code': location_code,
  900. 'old_status': old_status,
  901. 'new_status': location_status
  902. }
  903. }, status=status.HTTP_200_OK)
  904. else:
  905. return Response({
  906. 'success': False,
  907. 'message': '更新库位状态失败'
  908. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  909. except Exception as e:
  910. logger.error(f"更新库位状态失败: {str(e)}", exc_info=True)
  911. return Response({
  912. 'success': False,
  913. 'message': f'更新库位状态失败: {str(e)}'
  914. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  915. class UnbindContainerView(APIView):
  916. """解除托盘与库位的绑定关系"""
  917. def post(self, request):
  918. location_code = request.data.get('location_code')
  919. container_code = request.data.get('container_code') # 可选参数
  920. if not location_code:
  921. return Response({
  922. 'success': False,
  923. 'message': '缺少库位编码参数'
  924. }, status=status.HTTP_400_BAD_REQUEST)
  925. try:
  926. from bin.models import LocationModel, LocationContainerLink
  927. from bin.views import LocationAllocation
  928. # 验证库位是否存在
  929. location = LocationModel.objects.filter(
  930. location_code=location_code,
  931. is_active=True
  932. ).first()
  933. if not location:
  934. return Response({
  935. 'success': False,
  936. 'message': f'库位 {location_code} 不存在或已禁用'
  937. }, status=status.HTTP_404_NOT_FOUND)
  938. # 根据是否提供 container_code 来决定解除范围
  939. if container_code:
  940. # 只解除指定托盘的绑定
  941. from container.models import ContainerListModel
  942. container = ContainerListModel.objects.filter(
  943. container_code=container_code,
  944. is_delete=False
  945. ).first()
  946. if not container:
  947. return Response({
  948. 'success': False,
  949. 'message': f'托盘 {container_code} 不存在或已删除'
  950. }, status=status.HTTP_404_NOT_FOUND)
  951. # 获取该库位与该托盘的关联
  952. active_links = LocationContainerLink.objects.filter(
  953. location=location,
  954. container=container,
  955. is_active=True
  956. )
  957. if not active_links.exists():
  958. return Response({
  959. 'success': False,
  960. 'message': f'库位 {location_code} 与托盘 {container_code} 没有绑定关系'
  961. }, status=status.HTTP_400_BAD_REQUEST)
  962. unbound_count = active_links.count()
  963. with transaction.atomic():
  964. # 标记该关联为非活跃
  965. active_links.update(is_active=False)
  966. # 检查是否还有其他活跃关联
  967. remaining_links = LocationContainerLink.objects.filter(
  968. location=location,
  969. is_active=True
  970. )
  971. # 如果没有其他活跃关联,更新库位状态为可用
  972. if not remaining_links.exists():
  973. location.status = 'available'
  974. location.save()
  975. # 更新库位组状态
  976. allocator = LocationAllocation()
  977. allocator.update_location_group_status(location_code)
  978. logger.info(f"成功解除库位 {location_code} 与托盘 {container_code} 的绑定")
  979. return Response({
  980. 'success': True,
  981. 'code': '200',
  982. 'message': f'解除托盘 {container_code} 绑定成功',
  983. 'data': {
  984. 'location_code': location_code,
  985. 'container_code': container_code,
  986. 'unbound_count': unbound_count,
  987. 'remaining_links': remaining_links.count()
  988. }
  989. }, status=status.HTTP_200_OK)
  990. else:
  991. # 解除所有关联
  992. active_links = LocationContainerLink.objects.filter(
  993. location=location,
  994. is_active=True
  995. )
  996. if not active_links.exists():
  997. return Response({
  998. 'success': False,
  999. 'message': f'库位 {location_code} 没有绑定的托盘'
  1000. }, status=status.HTTP_400_BAD_REQUEST)
  1001. unbound_count = active_links.count()
  1002. with transaction.atomic():
  1003. # 标记所有关联为非活跃
  1004. active_links.update(is_active=False)
  1005. # 更新库位状态为可用
  1006. location.status = 'available'
  1007. location.save()
  1008. # 更新库位组状态
  1009. allocator = LocationAllocation()
  1010. allocator.update_location_group_status(location_code)
  1011. logger.info(f"成功解除库位 {location_code} 的所有托盘绑定")
  1012. return Response({
  1013. 'success': True,
  1014. 'code': '200',
  1015. 'message': '解除所有托盘绑定成功',
  1016. 'data': {
  1017. 'location_code': location_code,
  1018. 'unbound_count': unbound_count,
  1019. 'new_status': 'available'
  1020. }
  1021. }, status=status.HTTP_200_OK)
  1022. except Exception as e:
  1023. logger.error(f"解除托盘绑定失败: {str(e)}", exc_info=True)
  1024. return Response({
  1025. 'success': False,
  1026. 'message': f'解除托盘绑定失败: {str(e)}'
  1027. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)