views.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import csv
  2. from collections import defaultdict
  3. from decimal import Decimal
  4. from django.db import transaction
  5. from django.http import HttpResponse
  6. from rest_framework import status, viewsets
  7. from rest_framework.decorators import action
  8. from rest_framework.filters import OrderingFilter
  9. from rest_framework.response import Response
  10. from django_filters.rest_framework import DjangoFilterBackend
  11. from utils.page import MyPageNumberPagination
  12. from bin.models import LocationContainerLink
  13. from bound.models import BoundBatchModel
  14. from container.models import ContainerListModel, ContainerDetailModel
  15. from container.views import OutboundService
  16. from .models import CountReason, CountTask, CountTaskDetail
  17. from .serializers import (
  18. CountDetailSubmitSerializer,
  19. CountReasonSerializer,
  20. CountTaskDetailSerializer,
  21. CountTaskSerializer,
  22. TaskGenerateDetailSerializer,
  23. )
  24. class CountReasonViewSet(viewsets.ModelViewSet):
  25. """Master data for variance reasons."""
  26. queryset = CountReason.objects.all().order_by("sort", "code")
  27. serializer_class = CountReasonSerializer
  28. pagination_class = None
  29. filter_backends = [OrderingFilter]
  30. ordering_fields = ["sort", "code", "update_time"]
  31. def get_queryset(self):
  32. qs = super().get_queryset()
  33. flag = self.request.query_params.get("active_only")
  34. if flag in ("1", "true", "True"):
  35. qs = qs.filter(is_active=True)
  36. return qs
  37. class CountTaskViewSet(viewsets.ModelViewSet):
  38. """Cycle count task CRUD."""
  39. queryset = CountTask.objects.prefetch_related("details", "details__reason").order_by("-id")
  40. serializer_class = CountTaskSerializer
  41. pagination_class = MyPageNumberPagination
  42. filter_backends = [DjangoFilterBackend, OrderingFilter]
  43. filterset_fields = ["status", "task_type"]
  44. ordering_fields = ["id", "create_time", "update_time", "scheduled_at"]
  45. @action(detail=True, methods=["post"])
  46. def generate_details(self, request, pk=None):
  47. task = self.get_object()
  48. serializer = TaskGenerateDetailSerializer(data=request.data)
  49. serializer.is_valid(raise_exception=True)
  50. container_ids = serializer.validated_data.get("container_ids") or []
  51. batch_ids = serializer.validated_data.get("batch_ids") or []
  52. override = serializer.validated_data.get("override", False)
  53. containers = list(ContainerListModel.objects.filter(id__in=container_ids))
  54. if container_ids and not containers:
  55. return Response({"detail": "未找到托盘数据"}, status=status.HTTP_400_BAD_REQUEST)
  56. payloads = {}
  57. def add_payload(container, batch_number, goods_meta, qty):
  58. qty = qty if isinstance(qty, Decimal) else Decimal(str(qty or "0"))
  59. if qty <= 0:
  60. return
  61. normalized_batch = batch_number or ""
  62. if normalized_batch:
  63. normalized_batch = str(normalized_batch)
  64. key = (container.id if container else None, normalized_batch)
  65. base_payload = dict(
  66. task=task,
  67. container=container,
  68. container_code=str(container.container_code) if container else "",
  69. location_code=container.current_location if container else "",
  70. goods_code=goods_meta.get("goods_code", ""),
  71. goods_desc=goods_meta.get("goods_desc", ""),
  72. goods_std=goods_meta.get("goods_std", ""),
  73. goods_unit=goods_meta.get("goods_unit", ""),
  74. batch_number=normalized_batch,
  75. book_qty=qty,
  76. initial_count_qty=None,
  77. recount_qty=None,
  78. reason=None,
  79. counter_name="",
  80. recount_counter="",
  81. note="",
  82. final_qty=qty,
  83. variance_qty=Decimal("0"),
  84. status=CountTaskDetail.STATUS_PENDING,
  85. )
  86. if key in payloads:
  87. payloads[key]["book_qty"] += qty
  88. payloads[key]["final_qty"] = payloads[key]["book_qty"]
  89. else:
  90. payloads[key] = base_payload
  91. # 1) 托盘选取
  92. for container in containers:
  93. raw_batch_info = container.batch_info or []
  94. batch_info = raw_batch_info
  95. if batch_ids:
  96. batch_info = [info for info in raw_batch_info if info.get("batch_id") in batch_ids]
  97. if not batch_info:
  98. batch_info = raw_batch_info[:1] if raw_batch_info else [{}]
  99. for info in batch_info:
  100. goods_qty = info.get("qty", info.get("goods_qty", 0))
  101. goods_meta = {
  102. "goods_code": info.get("goods_code", ""),
  103. "goods_desc": info.get("goods_desc", ""),
  104. "goods_std": info.get("goods_std", ""),
  105. "goods_unit": info.get("goods_unit", ""),
  106. }
  107. batch_number = info.get("bound_number") or info.get("batch_number", "")
  108. add_payload(container, batch_number, goods_meta, goods_qty)
  109. # 2) 批次选取
  110. if batch_ids:
  111. batch_details = (
  112. ContainerDetailModel.objects.select_related("container", "batch")
  113. .filter(batch_id__in=batch_ids, is_delete=False)
  114. .exclude(status=3)
  115. )
  116. for detail in batch_details:
  117. container = detail.container
  118. if not container:
  119. continue
  120. available_qty = (detail.goods_qty or Decimal("0")) - (detail.goods_out_qty or Decimal("0"))
  121. batch = detail.batch
  122. goods_meta = {
  123. "goods_code": detail.goods_code,
  124. "goods_desc": detail.goods_desc,
  125. "goods_std": batch.goods_std if batch else "",
  126. "goods_unit": batch.goods_unit if batch else "",
  127. }
  128. batch_number = batch.bound_number if batch else ""
  129. add_payload(container, batch_number, goods_meta, available_qty)
  130. if not payloads:
  131. return Response(
  132. {"detail": "未找到符合条件的托盘或批次"},
  133. status=status.HTTP_400_BAD_REQUEST,
  134. )
  135. existing_details = {
  136. (detail.container_id, detail.batch_number or ""): detail for detail in task.details.all()
  137. }
  138. to_create = []
  139. created = 0
  140. updated = 0
  141. with transaction.atomic():
  142. for key, payload in payloads.items():
  143. if key in existing_details:
  144. if override:
  145. detail = existing_details[key]
  146. for field, value in payload.items():
  147. setattr(detail, field, value)
  148. detail.save()
  149. updated += 1
  150. continue
  151. to_create.append(CountTaskDetail(**payload))
  152. if to_create:
  153. CountTaskDetail.objects.bulk_create(to_create)
  154. created = len(to_create)
  155. task.refresh_statistics()
  156. return Response(
  157. {"created": created, "updated": updated, "total": task.total_details},
  158. status=status.HTTP_201_CREATED,
  159. )
  160. @action(detail=True, methods=["get"])
  161. def download_report(self, request, pk=None):
  162. task = self.get_object()
  163. response = HttpResponse(content_type="text/csv")
  164. filename = f"cyclecount_{task.doc_no}.csv"
  165. response["Content-Disposition"] = f'attachment; filename="{filename}"'
  166. writer = csv.writer(response)
  167. writer.writerow(
  168. ["序号", "物料名称", "规格", "单位", "托盘数量", "初盘", "再盘", "盘差数量", "原因及说明", "备注"]
  169. )
  170. for idx, detail in enumerate(task.details.select_related("reason").all(), start=1):
  171. writer.writerow(
  172. [
  173. idx,
  174. detail.goods_desc,
  175. detail.goods_std,
  176. detail.goods_unit,
  177. detail.book_qty,
  178. detail.initial_count_qty or "",
  179. detail.recount_qty or "",
  180. detail.variance_qty,
  181. detail.reason.description if detail.reason else "",
  182. detail.note,
  183. ]
  184. )
  185. return response
  186. @action(detail=True, methods=["post"], url_path="release")
  187. def release(self, request, pk=None):
  188. task = self.get_object()
  189. if task.status not in (CountTask.STATUS_DRAFT, CountTask.STATUS_RELEASED):
  190. return Response({"detail": "仅草稿任务可以下发"}, status=status.HTTP_400_BAD_REQUEST)
  191. details = list(task.details.select_related("container").all())
  192. if not details:
  193. return Response({"detail": "请先生成任务明细"}, status=status.HTTP_400_BAD_REQUEST)
  194. batch_containers = defaultdict(list)
  195. processed_pairs = set()
  196. skipped_messages = []
  197. for detail in details:
  198. container = detail.container
  199. if not container:
  200. skipped_messages.append(f"明细{detail.id}缺少托盘信息")
  201. continue
  202. batch_obj = None
  203. if detail.batch_number:
  204. batch_obj = BoundBatchModel.objects.filter(bound_number=detail.batch_number).first()
  205. if not batch_obj:
  206. batch_detail_qs = ContainerDetailModel.objects.filter(
  207. container=container, is_delete=False
  208. ).exclude(status=3)
  209. if detail.batch_number:
  210. batch_detail_qs = batch_detail_qs.filter(batch__bound_number=detail.batch_number)
  211. batch_detail = batch_detail_qs.select_related("batch").first()
  212. if batch_detail and batch_detail.batch:
  213. batch_obj = batch_detail.batch
  214. if not batch_obj:
  215. skipped_messages.append(f"托盘{container.container_code}无法匹配批次")
  216. continue
  217. location_link = (
  218. LocationContainerLink.objects.filter(container=container, is_active=True)
  219. .select_related("location")
  220. .first()
  221. )
  222. if not location_link or not location_link.location:
  223. skipped_messages.append(f"托盘{container.container_code}不在有效库位")
  224. continue
  225. pair_key = (container.id, batch_obj.id)
  226. if pair_key in processed_pairs:
  227. continue
  228. processed_pairs.add(pair_key)
  229. location = location_link.location
  230. batch_containers[batch_obj.id].append(
  231. {
  232. "container_number": container.id,
  233. "batch_id": batch_obj.id,
  234. "c_number": location.c_number or 0,
  235. "location_code": location.location_code,
  236. }
  237. )
  238. if not batch_containers:
  239. return Response(
  240. {
  241. "detail": "没有可下发的托盘",
  242. "skipped": skipped_messages,
  243. },
  244. status=status.HTTP_400_BAD_REQUEST,
  245. )
  246. created_batches = []
  247. total_containers = 0
  248. for batch_id, containers in batch_containers.items():
  249. result = OutboundService.create_initial_check_tasks(containers, batch_id)
  250. if result is False:
  251. skipped_messages.append(f"批次{batch_id}已有正在执行的抽检任务")
  252. continue
  253. created_batches.append(batch_id)
  254. total_containers += len(containers)
  255. if not created_batches:
  256. return Response(
  257. {
  258. "detail": "未能创建抽检任务",
  259. "skipped": skipped_messages,
  260. },
  261. status=status.HTTP_400_BAD_REQUEST,
  262. )
  263. OutboundService.process_next_task()
  264. CountTask.objects.filter(pk=task.pk).update(status=CountTask.STATUS_RELEASED)
  265. task.refresh_from_db(fields=["status", "update_time"])
  266. return Response(
  267. {
  268. "detail": "任务已下发",
  269. "released_batches": created_batches,
  270. "released_containers": total_containers,
  271. "skipped": skipped_messages,
  272. },
  273. status=status.HTTP_200_OK,
  274. )
  275. class CountTaskDetailViewSet(viewsets.ModelViewSet):
  276. """Task detail endpoint for PDA/Web."""
  277. queryset = CountTaskDetail.objects.select_related("task", "reason", "container").order_by("id")
  278. serializer_class = CountTaskDetailSerializer
  279. pagination_class = MyPageNumberPagination
  280. filter_backends = [DjangoFilterBackend, OrderingFilter]
  281. filterset_fields = ["task", "status", "container_code", "goods_code"]
  282. ordering_fields = ["id", "create_time", "update_time", "variance_qty"]
  283. @action(detail=True, methods=["post"], url_path="count")
  284. def submit_count(self, request, pk=None):
  285. detail = self.get_object()
  286. serializer = CountDetailSubmitSerializer(data=request.data)
  287. serializer.is_valid(raise_exception=True)
  288. qty = serializer.validated_data["qty"]
  289. round_type = serializer.validated_data["round_type"]
  290. reason = serializer.validated_data.get("reason_id")
  291. note = serializer.validated_data.get("note")
  292. counter_name = serializer.validated_data.get("counter_name")
  293. if round_type == CountDetailSubmitSerializer.ROUND_INITIAL:
  294. detail.initial_count_qty = qty
  295. if counter_name:
  296. detail.counter_name = counter_name
  297. # reset recount when redoing initial count
  298. detail.recount_qty = None
  299. detail.recount_counter = ""
  300. else:
  301. detail.recount_qty = qty
  302. if counter_name:
  303. detail.recount_counter = counter_name
  304. if reason:
  305. detail.reason = reason
  306. if note is not None:
  307. detail.note = note
  308. detail.save()
  309. output = CountTaskDetailSerializer(detail, context={"request": request})
  310. return Response(output.data, status=status.HTTP_200_OK)