| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- 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)
|