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