models.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. from decimal import Decimal
  2. from django.db import models
  3. from django.db.models import Count, Q
  4. from django.utils import timezone
  5. from container.models import ContainerListModel
  6. class CountReason(models.Model):
  7. """Variance reason master data."""
  8. code = models.CharField(max_length=32, unique=True, verbose_name="原因编码")
  9. description = models.CharField(max_length=255, verbose_name="原因说明")
  10. is_active = models.BooleanField(default=True, verbose_name="是否启用")
  11. sort = models.IntegerField(default=0, verbose_name="排序")
  12. note = models.CharField(max_length=255, blank=True, verbose_name="备注")
  13. create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
  14. update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
  15. class Meta:
  16. db_table = "cycle_count_reason"
  17. verbose_name = "盘点差异原因"
  18. verbose_name_plural = "盘点差异原因"
  19. ordering = ["sort", "code"]
  20. def __str__(self) -> str:
  21. return f"{self.code}-{self.description}"
  22. class CountTask(models.Model):
  23. """Cycle count task header."""
  24. TASK_FULL = "full"
  25. TASK_SAMPLE = "sample"
  26. TASK_ADHOC = "adhoc"
  27. TASK_TYPE_CHOICES = (
  28. (TASK_FULL, "全盘"),
  29. (TASK_SAMPLE, "抽盘"),
  30. (TASK_ADHOC, "临盘"),
  31. )
  32. STATUS_DRAFT = "draft"
  33. STATUS_RELEASED = "released"
  34. STATUS_IN_PROGRESS = "in_progress"
  35. STATUS_WAIT_RECOUNT = "wait_recount"
  36. STATUS_COMPLETED = "completed"
  37. STATUS_CLOSED = "closed"
  38. STATUS_CHOICES = (
  39. (STATUS_DRAFT, "草稿"),
  40. (STATUS_RELEASED, "已下发"),
  41. (STATUS_IN_PROGRESS, "进行中"),
  42. (STATUS_WAIT_RECOUNT, "待复盘"),
  43. (STATUS_COMPLETED, "已完成"),
  44. (STATUS_CLOSED, "已关闭"),
  45. )
  46. doc_no = models.CharField(
  47. max_length=40,
  48. unique=True,
  49. blank=True,
  50. verbose_name="任务单号",
  51. help_text="自动生成,也可手工录入",
  52. )
  53. task_type = models.CharField(
  54. max_length=16, choices=TASK_TYPE_CHOICES, default=TASK_FULL, verbose_name="任务类型"
  55. )
  56. status = models.CharField(
  57. max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name="状态"
  58. )
  59. source_batch = models.CharField(max_length=64, blank=True, verbose_name="来源批次")
  60. remark = models.CharField(max_length=255, blank=True, verbose_name="备注")
  61. scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name="计划盘点时间")
  62. created_by = models.CharField(max_length=64, blank=True, verbose_name="创建人")
  63. total_details = models.IntegerField(default=0, verbose_name="明细数量")
  64. counted_details = models.IntegerField(default=0, verbose_name="已盘数量")
  65. variance_details = models.IntegerField(default=0, verbose_name="差异数量")
  66. create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
  67. update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
  68. class Meta:
  69. db_table = "cycle_count_task"
  70. verbose_name = "盘点任务"
  71. verbose_name_plural = "盘点任务"
  72. ordering = ["-id"]
  73. def __str__(self) -> str:
  74. return self.doc_no
  75. @staticmethod
  76. def generate_doc_no() -> str:
  77. """Generate a readable unique code."""
  78. return timezone.now().strftime("CC%Y%m%d%H%M%S%f")
  79. def save(self, *args, **kwargs):
  80. if not self.doc_no:
  81. self.doc_no = self.generate_doc_no()
  82. super().save(*args, **kwargs)
  83. def refresh_statistics(self):
  84. """Refresh counters & status based on detail lines."""
  85. aggregates = self.details.aggregate(
  86. total=Count("id"),
  87. counted=Count(
  88. "id",
  89. filter=Q(initial_count_qty__isnull=False)
  90. | Q(recount_qty__isnull=False),
  91. ),
  92. variance=Count(
  93. "id",
  94. filter=Q(variance_qty__isnull=False)
  95. & ~Q(variance_qty=Decimal("0")),
  96. ),
  97. )
  98. total = aggregates.get("total") or 0
  99. counted = aggregates.get("counted") or 0
  100. variance = aggregates.get("variance") or 0
  101. current_status = self.status
  102. if total == 0:
  103. new_status = self.STATUS_DRAFT
  104. elif counted == 0:
  105. # 仍处于草稿态的任务不因生成/删除明细而自动下发
  106. if current_status == self.STATUS_DRAFT:
  107. new_status = self.STATUS_DRAFT
  108. else:
  109. new_status = self.STATUS_RELEASED
  110. elif counted < total:
  111. new_status = self.STATUS_IN_PROGRESS
  112. elif variance > 0:
  113. new_status = self.STATUS_WAIT_RECOUNT
  114. else:
  115. new_status = self.STATUS_COMPLETED
  116. CountTask.objects.filter(pk=self.pk).update(
  117. total_details=total,
  118. counted_details=counted,
  119. variance_details=variance,
  120. status=new_status,
  121. update_time=timezone.now(),
  122. )
  123. self.refresh_from_db(fields=["total_details", "counted_details", "variance_details", "status"])
  124. class CountTaskDetail(models.Model):
  125. """Cycle count detail lines."""
  126. STATUS_PENDING = "pending"
  127. STATUS_COUNTED = "counted"
  128. STATUS_VARIANCE = "variance"
  129. STATUS_COMPLETED = "completed"
  130. STATUS_CHOICES = (
  131. (STATUS_PENDING, "待盘点"),
  132. (STATUS_COUNTED, "已初盘"),
  133. (STATUS_VARIANCE, "待复盘"),
  134. (STATUS_COMPLETED, "已完成"),
  135. )
  136. task = models.ForeignKey(
  137. CountTask, related_name="details", on_delete=models.CASCADE, verbose_name="任务"
  138. )
  139. container = models.ForeignKey(
  140. ContainerListModel,
  141. related_name="count_details",
  142. null=True,
  143. blank=True,
  144. on_delete=models.SET_NULL,
  145. verbose_name="托盘",
  146. )
  147. container_code = models.CharField(max_length=64, blank=True, verbose_name="托盘编码")
  148. location_code = models.CharField(max_length=64, blank=True, verbose_name="库位")
  149. goods_code = models.CharField(max_length=64, blank=True, verbose_name="物料编码")
  150. goods_desc = models.CharField(max_length=255, blank=True, verbose_name="物料名称")
  151. goods_std = models.CharField(max_length=255, blank=True, verbose_name="规格型号")
  152. goods_unit = models.CharField(max_length=32, blank=True, verbose_name="单位")
  153. batch_number = models.CharField(max_length=64, blank=True, verbose_name="管理批次")
  154. book_qty = models.DecimalField(
  155. max_digits=14, decimal_places=3, default=Decimal("0"), verbose_name="账面数量"
  156. )
  157. initial_count_qty = models.DecimalField(
  158. max_digits=14, decimal_places=3, null=True, blank=True, verbose_name="初盘数量"
  159. )
  160. recount_qty = models.DecimalField(
  161. max_digits=14, decimal_places=3, null=True, blank=True, verbose_name="复盘数量"
  162. )
  163. final_qty = models.DecimalField(
  164. max_digits=14, decimal_places=3, default=Decimal("0"), verbose_name="最终数量"
  165. )
  166. variance_qty = models.DecimalField(
  167. max_digits=14, decimal_places=3, default=Decimal("0"), verbose_name="盘差数量"
  168. )
  169. reason = models.ForeignKey(
  170. CountReason,
  171. related_name="details",
  172. on_delete=models.SET_NULL,
  173. null=True,
  174. blank=True,
  175. verbose_name="差异原因",
  176. )
  177. counter_name = models.CharField(max_length=64, blank=True, verbose_name="初盘人")
  178. recount_counter = models.CharField(max_length=64, blank=True, verbose_name="复盘人")
  179. note = models.CharField(max_length=255, blank=True, verbose_name="备注")
  180. status = models.CharField(
  181. max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, verbose_name="状态"
  182. )
  183. create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
  184. update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
  185. class Meta:
  186. db_table = "cycle_count_task_detail"
  187. verbose_name = "盘点任务明细"
  188. verbose_name_plural = "盘点任务明细"
  189. ordering = ["id"]
  190. def __str__(self) -> str:
  191. return f"{self.task.doc_no}-{self.container_code or self.goods_code}"
  192. def sync_qty_fields(self):
  193. """Recalculate variance & final qty and derive status."""
  194. book_qty = self.book_qty or Decimal("0")
  195. final_qty = None
  196. if self.recount_qty is not None:
  197. final_qty = self.recount_qty
  198. elif self.initial_count_qty is not None:
  199. final_qty = self.initial_count_qty
  200. else:
  201. final_qty = book_qty
  202. self.final_qty = final_qty
  203. self.variance_qty = (final_qty or Decimal("0")) - book_qty
  204. if self.recount_qty is not None:
  205. self.status = self.STATUS_COMPLETED
  206. elif self.initial_count_qty is not None:
  207. if self.variance_qty == Decimal("0"):
  208. self.status = self.STATUS_COUNTED
  209. else:
  210. self.status = self.STATUS_VARIANCE
  211. else:
  212. self.status = self.STATUS_PENDING
  213. def save(self, *args, **kwargs):
  214. self.sync_qty_fields()
  215. super().save(*args, **kwargs)
  216. if self.task_id:
  217. self.task.refresh_statistics()
  218. def delete(self, *args, **kwargs):
  219. task = self.task
  220. super().delete(*args, **kwargs)
  221. if task:
  222. task.refresh_statistics()