views.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088
  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. # 构建查询条件
  230. filters = {'is_active': True, 'location_type__in': self.TARGET_LOCATION_TYPES}
  231. if self.warehouse_code:
  232. filters['warehouse_code'] = self.warehouse_code
  233. if self.layer is not None:
  234. filters['layer'] = self.layer
  235. # 获取库位并预取Link记录
  236. locations = LocationModel.objects.filter(**filters).prefetch_related('container_links')
  237. self.results['summary']['total_locations'] = locations.count()
  238. for location in locations:
  239. self.results['summary']['checked_locations'] += 1
  240. # 获取该库位的活跃Link记录数量
  241. active_links_count = location.container_links.filter(is_active=True).count()
  242. # 根据状态判断是否一致
  243. is_consistent, expected_status, error_type = self._check_location_consistency(
  244. location.status, active_links_count
  245. )
  246. if not is_consistent:
  247. self.results['summary']['error_locations'] += 1
  248. self.results['location_errors'].append({
  249. 'location_id': location.id,
  250. 'location_code': location.location_code,
  251. 'warehouse_code': location.warehouse_code,
  252. 'current_status': location.status,
  253. 'expected_status': expected_status,
  254. 'active_links_count': active_links_count,
  255. 'layer': location.layer,
  256. 'row': location.row,
  257. 'col': location.col,
  258. 'location_group': location.location_group,
  259. 'error_type': error_type,
  260. 'detected_at': timezone.now()
  261. })
  262. def _check_location_consistency(self, current_status, active_links_count):
  263. """
  264. 检查单个库位的状态一致性
  265. Returns:
  266. tuple: (是否一致, 预期状态, 错误类型)
  267. """
  268. if current_status == 'available':
  269. # available状态应该没有活跃的Link记录
  270. if active_links_count > 0:
  271. return False, 'occupied', 'available_but_has_links'
  272. elif current_status in ['occupied','reserved']:
  273. # occupied状态应该至少有一个活跃的Link记录
  274. if active_links_count != 1:
  275. return False, 'available', 'occupied_but_no_links'
  276. elif current_status in ['disabled', 'maintenance']:
  277. # 这些特殊状态可以有Link记录,但通常不应该有
  278. if active_links_count > 0:
  279. return False, current_status, 'special_status_has_links'
  280. return True, current_status, None
  281. def check_container_detail_status(self):
  282. """检测托盘明细状态的一致性"""
  283. from container.models import ContainerDetailModel
  284. from bin.models import LocationContainerLink
  285. details_qs = (
  286. ContainerDetailModel.objects.filter(is_delete=False)
  287. .select_related('container', 'batch')
  288. .prefetch_related(
  289. Prefetch(
  290. 'container__location_links',
  291. queryset=LocationContainerLink.objects.filter(is_active=True).select_related('location'),
  292. to_attr='active_links'
  293. )
  294. )
  295. )
  296. total_checked = 0
  297. for detail in details_qs:
  298. if not detail.container:
  299. continue
  300. if not self._detail_in_scope(detail):
  301. continue
  302. goods_qty = self._to_decimal(detail.goods_qty)
  303. goods_out_qty = self._to_decimal(detail.goods_out_qty)
  304. remaining_qty = goods_qty - goods_out_qty
  305. expected_status, error_type = self._determine_detail_status(remaining_qty)
  306. total_checked += 1
  307. self.results['summary']['checked_details'] += 1
  308. if expected_status is None:
  309. continue
  310. if detail.status != expected_status:
  311. self.results['summary']['error_details'] += 1
  312. self.results['detail_errors'].append({
  313. 'detail_id': detail.id,
  314. 'container_id': detail.container_id,
  315. 'container_code': getattr(detail.container, 'container_code', None),
  316. 'batch_id': detail.batch_id,
  317. 'batch_number': getattr(detail.batch, 'bound_number', None) if detail.batch else None,
  318. 'goods_qty': str(goods_qty),
  319. 'goods_out_qty': str(goods_out_qty),
  320. 'remaining_qty': str(remaining_qty),
  321. 'current_status': detail.status,
  322. 'current_status_display': self._status_display(detail.status),
  323. 'expected_status': expected_status,
  324. 'expected_status_display': self._status_display(expected_status),
  325. 'error_type': error_type,
  326. 'detected_at': timezone.now()
  327. })
  328. self.results['summary']['total_details'] = total_checked
  329. def _detail_in_scope(self, detail):
  330. """判断托盘明细是否在当前检测范围内"""
  331. if not (self.warehouse_code or self.layer):
  332. return True
  333. active_links = getattr(detail.container, 'active_links', None)
  334. if not active_links:
  335. return False
  336. warehouse_ok = True
  337. layer_ok = True
  338. if self.warehouse_code:
  339. warehouse_ok = any(link.location.warehouse_code == self.warehouse_code for link in active_links)
  340. if self.layer is not None:
  341. layer_ok = any(link.location.layer == self.layer for link in active_links)
  342. return warehouse_ok and layer_ok
  343. def _to_decimal(self, value):
  344. if isinstance(value, Decimal):
  345. return value
  346. if value is None:
  347. return Decimal('0')
  348. try:
  349. return Decimal(str(value))
  350. except (InvalidOperation, TypeError, ValueError):
  351. return Decimal('0')
  352. def _determine_detail_status(self, remaining_qty):
  353. if remaining_qty > 0:
  354. return 2, 'detail_should_be_in_stock'
  355. return 3, 'detail_should_be_outbound'
  356. def _status_display(self, status):
  357. status_map = {
  358. 0: '空盘',
  359. 2: '在盘',
  360. 3: '离库'
  361. }
  362. return status_map.get(status, str(status) if status is not None else '未知')
  363. def check_group_consistency(self):
  364. """检测库位组状态的一致性"""
  365. from bin.models import LocationGroupModel
  366. # 构建查询条件
  367. filters = {'is_active': True}
  368. if self.warehouse_code:
  369. filters['warehouse_code'] = self.warehouse_code
  370. if self.layer is not None:
  371. filters['layer'] = self.layer
  372. # 获取库位组并预取关联的库位
  373. groups = LocationGroupModel.objects.filter(**filters).prefetch_related('location_items')
  374. self.results['summary']['total_groups'] = groups.count()
  375. for group in groups:
  376. self.results['summary']['checked_groups'] += 1
  377. # 获取组内所有库位
  378. group_locations = group.location_items.filter(is_active=True)
  379. # 统计组内库位的状态分布
  380. status_counts = self._get_group_status_distribution(group_locations)
  381. total_locations = group_locations.count()
  382. # 根据组内库位状态推断组应该的状态
  383. is_consistent, expected_status, error_type = self._check_group_consistency(
  384. group.status, status_counts, total_locations
  385. )
  386. if not is_consistent:
  387. self.results['summary']['error_groups'] += 1
  388. self.results['group_errors'].append({
  389. 'group_id': group.id,
  390. 'group_code': group.group_code,
  391. 'group_name': group.group_name,
  392. 'warehouse_code': group.warehouse_code,
  393. 'current_status': group.status,
  394. 'expected_status': expected_status,
  395. 'total_locations': total_locations,
  396. 'status_distribution': status_counts,
  397. 'layer': group.layer,
  398. 'error_type': error_type,
  399. 'detected_at': timezone.now()
  400. })
  401. def _get_group_status_distribution(self, group_locations):
  402. """获取组内库位状态分布"""
  403. return group_locations.aggregate(
  404. available_count=Count('id', filter=Q(status='available')),
  405. occupied_count=Count('id', filter=Q(status='occupied')),
  406. disabled_count=Count('id', filter=Q(status='disabled')),
  407. reserved_count=Count('id', filter=Q(status='reserved')),
  408. maintenance_count=Count('id', filter=Q(status='maintenance'))
  409. )
  410. def _check_group_consistency(self, current_status, status_counts, total_locations):
  411. """
  412. 检查库位组状态一致性
  413. Returns:
  414. tuple: (是否一致, 预期状态, 错误类型)
  415. """
  416. if total_locations == 0:
  417. # 空组应该是available或disabled
  418. if current_status not in ['available', 'disabled']:
  419. return False, 'available', 'empty_group_wrong_status'
  420. else:
  421. # 根据库位状态分布推断组状态
  422. if status_counts['occupied_count'] == total_locations:
  423. # 所有库位都被占用,组状态应该是full
  424. if current_status != 'full':
  425. return False, 'full', 'all_occupied_but_not_full'
  426. elif status_counts['occupied_count'] > 0:
  427. # 有库位被占用,组状态应该是occupied
  428. if current_status != 'occupied':
  429. return False, 'occupied', 'has_occupied_but_wrong_status'
  430. elif status_counts['available_count'] == total_locations:
  431. # 所有库位都可用,组状态应该是available
  432. if current_status != 'available':
  433. return False, 'available', 'all_available_but_wrong_status'
  434. # 检查特殊状态
  435. elif status_counts['disabled_count'] > 0 and current_status != 'disabled':
  436. return False, 'disabled', 'has_disabled_but_wrong_status'
  437. elif status_counts['maintenance_count'] > 0 and current_status != 'maintenance':
  438. return False, 'maintenance', 'has_maintenance_but_wrong_status'
  439. return True, current_status, None
  440. def fix_detected_issues(self):
  441. """修复检测到的不一致问题"""
  442. logger.info("开始修复检测到的不一致问题")
  443. # 修复库位状态不一致
  444. if 'locations' in self.fix_scope and self.results['location_errors']:
  445. self._fix_location_issues()
  446. # 修复托盘明细状态不一致
  447. if 'details' in self.fix_scope and self.results['detail_errors']:
  448. self._fix_detail_issues()
  449. # 修复库位组状态不一致
  450. if 'groups' in self.fix_scope and self.results['group_errors']:
  451. self._fix_group_issues()
  452. logger.info(f"修复完成: {self.results['summary']['fixed_location_count']}个库位, "
  453. f"{self.results['summary']['fixed_group_count']}个库位组, "
  454. f"{self.results['summary']['fixed_detail_count']}条托盘明细")
  455. def _fix_location_issues(self):
  456. """修复库位状态不一致问题"""
  457. from bin.models import LocationModel
  458. for error in self.results['location_errors']:
  459. try:
  460. with transaction.atomic():
  461. location = LocationModel.objects.select_for_update().get(id=error['location_id'])
  462. # 只有明确有预期状态时才修复
  463. if error['expected_status'] and error['expected_status'] != 'need_check':
  464. old_status = location.status
  465. location.status = error['expected_status']
  466. location.save()
  467. self.results['summary']['fixed_location_count'] += 1
  468. self.results['fixed_locations'].append({
  469. 'location_id': location.id,
  470. 'location_code': location.location_code,
  471. 'old_status': old_status,
  472. 'new_status': location.status,
  473. 'fixed_at': timezone.now(),
  474. 'error_type': error['error_type']
  475. })
  476. else:
  477. # 标记为需要手动检查
  478. self.results['fixed_locations'].append({
  479. 'location_id': location.id,
  480. 'location_code': location.location_code,
  481. 'status': '需要手动检查',
  482. 'reason': '无法自动确定正确状态',
  483. 'error_type': error['error_type']
  484. })
  485. except Exception as e:
  486. logger.error(f"修复库位{error.get('location_id')}时出错: {str(e)}")
  487. self.results['repair_errors'].append({
  488. 'type': 'location_fix_error',
  489. 'location_id': error.get('location_id'),
  490. 'error_message': str(e),
  491. 'error_type': error.get('error_type')
  492. })
  493. def _fix_group_issues(self):
  494. """修复库位组状态不一致问题"""
  495. from bin.models import LocationGroupModel
  496. for error in self.results['group_errors']:
  497. try:
  498. with transaction.atomic():
  499. group = LocationGroupModel.objects.select_for_update().get(id=error['group_id'])
  500. # 只有明确有预期状态时才修复
  501. if error['expected_status'] and error['expected_status'] != 'need_check':
  502. old_status = group.status
  503. group.status = error['expected_status']
  504. group.save()
  505. self.results['summary']['fixed_group_count'] += 1
  506. self.results['fixed_groups'].append({
  507. 'group_id': group.id,
  508. 'group_code': group.group_code,
  509. 'old_status': old_status,
  510. 'new_status': group.status,
  511. 'fixed_at': timezone.now(),
  512. 'error_type': error['error_type']
  513. })
  514. else:
  515. # 标记为需要手动检查
  516. self.results['fixed_groups'].append({
  517. 'group_id': group.id,
  518. 'group_code': group.group_code,
  519. 'status': '需要手动检查',
  520. 'reason': '无法自动确定正确状态',
  521. 'error_type': error.get('error_type')
  522. })
  523. except Exception as e:
  524. logger.error(f"修复库位组{error.get('group_id')}时出错: {str(e)}")
  525. self.results['repair_errors'].append({
  526. 'type': 'group_fix_error',
  527. 'group_id': error.get('group_id'),
  528. 'error_message': str(e),
  529. 'error_type': error.get('error_type')
  530. })
  531. def _fix_detail_issues(self):
  532. """修复托盘明细状态不一致问题"""
  533. from container.models import ContainerDetailModel
  534. for error in self.results['detail_errors']:
  535. expected_status = error.get('expected_status')
  536. if expected_status not in [2, 3]:
  537. continue
  538. try:
  539. with transaction.atomic():
  540. detail = ContainerDetailModel.objects.select_for_update().select_related('container').get(id=error['detail_id'])
  541. if detail.status == expected_status:
  542. continue
  543. old_status = detail.status
  544. detail.status = expected_status
  545. detail.save(update_fields=['status'])
  546. self.results['summary']['fixed_detail_count'] += 1
  547. error['fixed'] = True
  548. error['fixed_at'] = timezone.now()
  549. error['new_status'] = expected_status
  550. error['new_status_display'] = self._status_display(expected_status)
  551. self.results['fixed_details'].append({
  552. 'detail_id': detail.id,
  553. 'container_code': getattr(detail.container, 'container_code', None),
  554. 'old_status': old_status,
  555. 'new_status': expected_status,
  556. 'fixed_at': timezone.now(),
  557. 'error_type': error.get('error_type')
  558. })
  559. except ContainerDetailModel.DoesNotExist:
  560. logger.warning(f"托盘明细 {error.get('detail_id')} 不存在,跳过修复")
  561. except Exception as e:
  562. logger.error(f"修复托盘明细{error.get('detail_id')}失败: {str(e)}")
  563. self.results['repair_errors'].append({
  564. 'type': 'detail_fix_error',
  565. 'detail_id': error.get('detail_id'),
  566. 'error_message': str(e),
  567. 'error_type': error.get('error_type')
  568. })
  569. def get_summary(self):
  570. """获取检测摘要"""
  571. return {
  572. 'check_time': self.results['check_time'],
  573. 'total_checked': {
  574. 'locations': self.results['summary']['checked_locations'],
  575. 'groups': self.results['summary']['checked_groups'],
  576. 'details': self.results['summary']['checked_details']
  577. },
  578. 'errors_found': {
  579. 'locations': self.results['summary']['error_locations'],
  580. 'groups': self.results['summary']['error_groups'],
  581. 'details': self.results['summary']['error_details']
  582. },
  583. 'fixed': {
  584. 'locations': self.results['summary']['fixed_location_count'],
  585. 'groups': self.results['summary']['fixed_group_count'],
  586. 'details': self.results['summary']['fixed_detail_count']
  587. },
  588. 'has_errors': len(self.results['repair_errors']) > 0
  589. }
  590. def generate_report(self):
  591. """生成检测报告"""
  592. summary = self.get_summary()
  593. report = {
  594. 'summary': summary,
  595. 'details': {
  596. 'location_errors': self.results['location_errors'],
  597. 'group_errors': self.results['group_errors'],
  598. 'detail_errors': self.results['detail_errors'],
  599. 'fixed_locations': self.results['fixed_locations'],
  600. 'fixed_groups': self.results['fixed_groups'],
  601. 'fixed_details': self.results['fixed_details'],
  602. 'repair_errors': self.results['repair_errors']
  603. }
  604. }
  605. return report
  606. # 便捷函数
  607. def check_location_consistency(warehouse_code=None, layer=None, auto_fix=False):
  608. """
  609. 便捷函数:检查库位一致性
  610. Args:
  611. warehouse_code: 仓库代码
  612. layer: 楼层
  613. auto_fix: 是否自动修复
  614. Returns:
  615. dict: 检测结果
  616. """
  617. checker = LocationConsistencyChecker(warehouse_code, layer, auto_fix)
  618. return checker.check_all()
  619. def get_consistency_report(warehouse_code=None, layer=None, auto_fix=False):
  620. """
  621. 获取详细的检测报告
  622. Args:
  623. warehouse_code: 仓库代码
  624. layer: 楼层
  625. auto_fix: 是否自动修复
  626. Returns:
  627. dict: 检测报告
  628. """
  629. checker = LocationConsistencyChecker(warehouse_code, layer, auto_fix)
  630. checker.check_all()
  631. return checker.generate_report()
  632. class BindContainerView(APIView):
  633. """重新绑定托盘到库位"""
  634. def post(self, request):
  635. location_code = request.data.get('location_code')
  636. container_code = request.data.get('container_code')
  637. if not location_code:
  638. return Response({
  639. 'success': False,
  640. 'message': '缺少库位编码参数'
  641. }, status=status.HTTP_400_BAD_REQUEST)
  642. if not container_code:
  643. return Response({
  644. 'success': False,
  645. 'message': '缺少托盘编码参数'
  646. }, status=status.HTTP_400_BAD_REQUEST)
  647. try:
  648. from bin.models import LocationModel, LocationContainerLink
  649. from container.models import ContainerListModel
  650. from bin.views import LocationAllocation
  651. if len(location_code.split('-')) == 4:
  652. location_row = location_code.split('-')[1]
  653. location_col = location_code.split('-')[2]
  654. location_layer = location_code.split('-')[3]
  655. location = LocationModel.objects.filter(
  656. row=location_row,
  657. col=location_col,
  658. layer=location_layer,
  659. is_active=True).first()
  660. else:
  661. location = LocationModel.objects.filter(
  662. location_code=location_code,
  663. is_active=True
  664. ).first()
  665. if not location:
  666. return Response({
  667. 'success': False,
  668. 'message': f'库位 {location_code} 不存在或已禁用'
  669. }, status=status.HTTP_404_NOT_FOUND)
  670. # 验证托盘是否存在
  671. container = ContainerListModel.objects.filter(
  672. container_code=container_code
  673. ).first()
  674. if not container:
  675. return Response({
  676. 'success': False,
  677. 'message': f'托盘 {container_code} 不存在'
  678. }, status=status.HTTP_404_NOT_FOUND)
  679. # 使用 LocationAllocation 的方法来更新关联
  680. allocator = LocationAllocation()
  681. # 先解除该库位的所有现有关联
  682. LocationContainerLink.objects.filter(
  683. location=location,
  684. is_active=True
  685. ).update(is_active=False)
  686. # 创建新的关联
  687. with transaction.atomic():
  688. # 检查是否已存在关联(即使是非活跃的)
  689. existing_link = LocationContainerLink.objects.filter(
  690. location=location,
  691. container=container
  692. ).first()
  693. if existing_link:
  694. # 如果存在,重新激活
  695. existing_link.is_active = True
  696. existing_link.save()
  697. else:
  698. # 创建新关联
  699. LocationContainerLink.objects.create(
  700. location=location,
  701. container=container,
  702. is_active=True,
  703. operator=request.auth.name if request.auth else None,
  704. )
  705. # 更新库位状态为占用
  706. location.status = 'occupied'
  707. location.save()
  708. # 更新库位组状态
  709. allocator.update_location_group_status(location_code)
  710. logger.info(f"成功重新绑定托盘 {container_code} 到库位 {location_code}")
  711. return Response({
  712. 'success': True,
  713. 'code': '200',
  714. 'message': '重新绑定托盘成功',
  715. 'data': {
  716. 'location_code': location_code,
  717. 'container_code': container_code,
  718. 'status': 'occupied'
  719. }
  720. }, status=status.HTTP_200_OK)
  721. except Exception as e:
  722. logger.error(f"重新绑定托盘失败: {str(e)}", exc_info=True)
  723. return Response({
  724. 'success': False,
  725. 'message': f'重新绑定托盘失败: {str(e)}'
  726. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  727. class UpdateLocationStatusView(APIView):
  728. """更新库位状态"""
  729. def post(self, request):
  730. location_code = request.data.get('location_code')
  731. location_status = request.data.get('status') # 重命名变量避免与 status 模块冲突
  732. if not location_code:
  733. return Response({
  734. 'success': False,
  735. 'message': '缺少库位编码参数'
  736. }, status=status.HTTP_400_BAD_REQUEST)
  737. if not location_status:
  738. return Response({
  739. 'success': False,
  740. 'message': '缺少状态参数'
  741. }, status=status.HTTP_400_BAD_REQUEST)
  742. # 验证状态值
  743. valid_statuses = ['available', 'occupied', 'disabled', 'reserved', 'maintenance']
  744. if location_status not in valid_statuses:
  745. return Response({
  746. 'success': False,
  747. 'message': f'无效的状态值,允许的值: {", ".join(valid_statuses)}'
  748. }, status=status.HTTP_400_BAD_REQUEST)
  749. try:
  750. from bin.models import LocationModel
  751. from bin.views import LocationAllocation
  752. # 验证库位是否存在
  753. location = LocationModel.objects.filter(
  754. location_code=location_code,
  755. is_active=True
  756. ).first()
  757. if not location:
  758. return Response({
  759. 'success': False,
  760. 'message': f'库位 {location_code} 不存在或已禁用'
  761. }, status=status.HTTP_404_NOT_FOUND)
  762. # 保存旧状态
  763. old_status = location.status
  764. # 使用 LocationAllocation 的方法来更新状态
  765. allocator = LocationAllocation()
  766. result = allocator.update_location_status(location_code, location_status, request=request)
  767. if result:
  768. logger.info(f"成功更新库位 {location_code} 状态为 {location_status}")
  769. return Response({
  770. 'success': True,
  771. 'code': '200',
  772. 'message': '更新库位状态成功',
  773. 'data': {
  774. 'location_code': location_code,
  775. 'old_status': old_status,
  776. 'new_status': location_status
  777. }
  778. }, status=status.HTTP_200_OK)
  779. else:
  780. return Response({
  781. 'success': False,
  782. 'message': '更新库位状态失败'
  783. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  784. except Exception as e:
  785. logger.error(f"更新库位状态失败: {str(e)}", exc_info=True)
  786. return Response({
  787. 'success': False,
  788. 'message': f'更新库位状态失败: {str(e)}'
  789. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  790. class UnbindContainerView(APIView):
  791. """解除托盘与库位的绑定关系"""
  792. def post(self, request):
  793. location_code = request.data.get('location_code')
  794. container_code = request.data.get('container_code') # 可选参数
  795. if not location_code:
  796. return Response({
  797. 'success': False,
  798. 'message': '缺少库位编码参数'
  799. }, status=status.HTTP_400_BAD_REQUEST)
  800. try:
  801. from bin.models import LocationModel, LocationContainerLink
  802. from bin.views import LocationAllocation
  803. # 验证库位是否存在
  804. location = LocationModel.objects.filter(
  805. location_code=location_code,
  806. is_active=True
  807. ).first()
  808. if not location:
  809. return Response({
  810. 'success': False,
  811. 'message': f'库位 {location_code} 不存在或已禁用'
  812. }, status=status.HTTP_404_NOT_FOUND)
  813. # 根据是否提供 container_code 来决定解除范围
  814. if container_code:
  815. # 只解除指定托盘的绑定
  816. from container.models import ContainerListModel
  817. container = ContainerListModel.objects.filter(
  818. container_code=container_code,
  819. is_delete=False
  820. ).first()
  821. if not container:
  822. return Response({
  823. 'success': False,
  824. 'message': f'托盘 {container_code} 不存在或已删除'
  825. }, status=status.HTTP_404_NOT_FOUND)
  826. # 获取该库位与该托盘的关联
  827. active_links = LocationContainerLink.objects.filter(
  828. location=location,
  829. container=container,
  830. is_active=True
  831. )
  832. if not active_links.exists():
  833. return Response({
  834. 'success': False,
  835. 'message': f'库位 {location_code} 与托盘 {container_code} 没有绑定关系'
  836. }, status=status.HTTP_400_BAD_REQUEST)
  837. unbound_count = active_links.count()
  838. with transaction.atomic():
  839. # 标记该关联为非活跃
  840. active_links.update(is_active=False)
  841. # 检查是否还有其他活跃关联
  842. remaining_links = LocationContainerLink.objects.filter(
  843. location=location,
  844. is_active=True
  845. )
  846. # 如果没有其他活跃关联,更新库位状态为可用
  847. if not remaining_links.exists():
  848. location.status = 'available'
  849. location.save()
  850. # 更新库位组状态
  851. allocator = LocationAllocation()
  852. allocator.update_location_group_status(location_code)
  853. logger.info(f"成功解除库位 {location_code} 与托盘 {container_code} 的绑定")
  854. return Response({
  855. 'success': True,
  856. 'code': '200',
  857. 'message': f'解除托盘 {container_code} 绑定成功',
  858. 'data': {
  859. 'location_code': location_code,
  860. 'container_code': container_code,
  861. 'unbound_count': unbound_count,
  862. 'remaining_links': remaining_links.count()
  863. }
  864. }, status=status.HTTP_200_OK)
  865. else:
  866. # 解除所有关联
  867. active_links = LocationContainerLink.objects.filter(
  868. location=location,
  869. is_active=True
  870. )
  871. if not active_links.exists():
  872. return Response({
  873. 'success': False,
  874. 'message': f'库位 {location_code} 没有绑定的托盘'
  875. }, status=status.HTTP_400_BAD_REQUEST)
  876. unbound_count = active_links.count()
  877. with transaction.atomic():
  878. # 标记所有关联为非活跃
  879. active_links.update(is_active=False)
  880. # 更新库位状态为可用
  881. location.status = 'available'
  882. location.save()
  883. # 更新库位组状态
  884. allocator = LocationAllocation()
  885. allocator.update_location_group_status(location_code)
  886. logger.info(f"成功解除库位 {location_code} 的所有托盘绑定")
  887. return Response({
  888. 'success': True,
  889. 'code': '200',
  890. 'message': '解除所有托盘绑定成功',
  891. 'data': {
  892. 'location_code': location_code,
  893. 'unbound_count': unbound_count,
  894. 'new_status': 'available'
  895. }
  896. }, status=status.HTTP_200_OK)
  897. except Exception as e:
  898. logger.error(f"解除托盘绑定失败: {str(e)}", exc_info=True)
  899. return Response({
  900. 'success': False,
  901. 'message': f'解除托盘绑定失败: {str(e)}'
  902. }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)