views.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. from rest_framework.views import APIView
  2. from rest_framework.response import Response
  3. from rest_framework.permissions import AllowAny
  4. from rest_framework import status
  5. from rest_framework import viewsets
  6. from django_filters.rest_framework import DjangoFilterBackend
  7. from rest_framework.filters import OrderingFilter
  8. from django.utils import timezone
  9. from django.db.models import Q, F, Case, When
  10. from datetime import datetime
  11. from collections import defaultdict
  12. from utils.page import MyPageNumberPagination
  13. from .models import *
  14. from .serializers import *
  15. from .filter import *
  16. import logging
  17. from django.db import transaction
  18. from bound.models import BoundListModel, BoundBatchModel, OutBatchModel,BoundDetailModel,OutBoundDetailModel,BatchLogModel
  19. logger = logging.getLogger('wms.boundBill')
  20. class WMSResponse:
  21. """
  22. 入库申请专用响应格式
  23. 使用示例:
  24. return WMSResponse.success(data=serializer.data, total=2, success=2)
  25. return WMSResponse.error(message='验证失败', total=2, fail_count=1,
  26. fail_materials=[{...}])
  27. """
  28. @staticmethod
  29. def success(data, total, success, status=status.HTTP_201_CREATED):
  30. """成功响应格式"""
  31. logger.info('成功响应 | 数据: %s', data)
  32. return Response({
  33. "status": True,
  34. "errorCode": status,
  35. "message": "success",
  36. "data": {
  37. "failCount": 0,
  38. "totalCount": total,
  39. "successCount": success,
  40. "fail_materials": []
  41. }
  42. }, status=status)
  43. @staticmethod
  44. def error(message, total, fail_count, fail_materials=None,
  45. status=status.HTTP_400_BAD_REQUEST, exception=None):
  46. """错误响应格式"""
  47. if fail_materials is None:
  48. fail_materials = []
  49. # 记录详细错误日志
  50. if exception:
  51. logger.error(f"入库申请错误: {message}", exc_info=exception)
  52. return Response({
  53. "status": False,
  54. "errorCode": status,
  55. "message": message,
  56. "data": {
  57. "failCount": fail_count,
  58. "totalCount": total,
  59. "successCount": total - fail_count,
  60. "fail_materials": fail_materials
  61. }
  62. }, status=status)
  63. class InboundApplyCreate(APIView):
  64. """
  65. 生产入库申请
  66. """
  67. authentication_classes = []
  68. permission_classes = [AllowAny]
  69. def post(self, request):
  70. logger.info('生产入库申请请求 | 原始数据: %s', request.data)
  71. try:
  72. total_count = len(request.data.get('materials', []))
  73. if total_count == 0 :
  74. return WMSResponse.error(
  75. message="物料清单不能为空",
  76. total=0,
  77. fail_count=0
  78. )
  79. if total_count != request.data.get('totalCount', 0):
  80. return WMSResponse.error(
  81. message="物料数量不匹配",
  82. total=total_count,
  83. fail_count=total_count
  84. )
  85. serializer = boundPostSerializer(data=request.data)
  86. if not serializer.is_valid():
  87. return self._handle_validation_error(serializer.errors, total_count)
  88. unique_result,error_details = self.find_unique_billid_and_number(serializer)
  89. if not unique_result:
  90. return WMSResponse.error(
  91. message="单据编号或原始单据ID重复",
  92. total=total_count,
  93. fail_count=total_count,
  94. fail_materials=[{
  95. "entryIds": None,
  96. "production_batch": None,
  97. "errors": error_details
  98. }]
  99. )
  100. # 保存或更新入库单
  101. bound_bill = self.save_or_update_inbound_bill(serializer)
  102. # 保存或更新物料明细
  103. self.save_or_update_material_detail(bound_bill, serializer)
  104. return WMSResponse.success(
  105. data=serializer.data,
  106. total=total_count,
  107. success=total_count
  108. )
  109. except Exception as e:
  110. logger.exception("服务器内部错误")
  111. return WMSResponse.error(
  112. message="系统处理异常",
  113. total=total_count,
  114. fail_count=total_count,
  115. status=status.HTTP_500_INTERNAL_SERVER_ERROR,
  116. exception=e
  117. )
  118. def _handle_validation_error(self, errors, total_count):
  119. """增强错误解析"""
  120. fail_materials = []
  121. # 提取嵌套错误信息
  122. material_errors = errors.get('materials', [])
  123. for error in material_errors:
  124. # 解析DRF的错误结构
  125. if isinstance(error, dict) and 'metadata' in error:
  126. fail_materials.append({
  127. "entryIds": error['metadata'].get('entryIds'),
  128. "production_batch": error['metadata'].get('production_batch'),
  129. "errors": {
  130. "missing_fields": error['metadata']['missing_fields'],
  131. "message": error['detail']
  132. }
  133. })
  134. return WMSResponse.error(
  135. message="物料数据不完整",
  136. total=total_count,
  137. fail_count=len(fail_materials),
  138. fail_materials=fail_materials
  139. )
  140. def _format_material_errors(self, error_dict):
  141. """格式化单个物料的错误信息"""
  142. return {
  143. field: details[0] if isinstance(details, list) else details
  144. for field, details in error_dict.items()
  145. }
  146. def find_unique_billid_and_number(self, serializer):
  147. """增强版唯一性验证"""
  148. bill_id = serializer.validated_data['billId']
  149. number = serializer.validated_data['number']
  150. # 使用Q对象进行联合查询
  151. duplicates_id = InboundBill.objects.filter(
  152. Q(billId=bill_id)
  153. ).only('billId')
  154. # 使用Q对象进行联合查询
  155. duplicates_nu = InboundBill.objects.filter(
  156. Q(number=number)
  157. ).only( 'number')
  158. error_details = {}
  159. # 检查单据编号重复
  160. if any(obj.billId != bill_id for obj in duplicates_nu):
  161. error_details['number'] = ["number入库单编码已存在,但是系统中与之前入库单单据ID不一致,请检查"]
  162. # 检查业务编号重复
  163. if any(obj.number != number for obj in duplicates_id):
  164. error_details['billId'] = ["billId入库单单据ID已存在,但是系统中与之前入库申请单编码不一致,请检查"]
  165. if error_details:
  166. return False,error_details
  167. return True,None
  168. def save_or_update_inbound_bill(self, serializer):
  169. """保存或更新入库单"""
  170. # 保存或更新入库单
  171. try:
  172. bound_bill = InboundBill.objects.get(billId=serializer.validated_data['billId'])
  173. bound_bill.number = serializer.validated_data['number']
  174. bound_bill.type = serializer.validated_data['type']
  175. bound_bill.date = serializer.validated_data['date']
  176. bound_bill.department = serializer.validated_data['department']
  177. bound_bill.warehouse = serializer.validated_data['warehouse']
  178. bound_bill.creater = serializer.validated_data['creater']
  179. bound_bill.note = (serializer.validated_data['note'] if 'note' in serializer.validated_data else '')
  180. bound_bill.totalCount = serializer.validated_data['totalCount']
  181. bound_bill.update_time = timezone.now()
  182. bound_bill.save()
  183. except InboundBill.DoesNotExist:
  184. bound_bill = InboundBill.objects.create(
  185. billId=serializer.validated_data['billId'],
  186. number=serializer.validated_data['number'],
  187. type=serializer.validated_data['type'],
  188. date=serializer.validated_data['date'],
  189. department=serializer.validated_data['department'],
  190. warehouse=serializer.validated_data['warehouse'],
  191. creater=serializer.validated_data['creater'],
  192. note=(serializer.validated_data['note'] if 'note' in serializer.validated_data else ''),
  193. totalCount=serializer.validated_data['totalCount'],
  194. create_time=timezone.now(),
  195. update_time=timezone.now()
  196. )
  197. return bound_bill
  198. def save_or_update_material_detail(self, bound_bill, serializer):
  199. """保存或更新物料明细"""
  200. # 保存或更新物料明细
  201. for item in serializer.validated_data['materials']:
  202. try:
  203. material_detail = MaterialDetail.objects.get(bound_billId=bound_bill, entryIds=item['entryIds'])
  204. material_detail.production_batch = item['production_batch']
  205. material_detail.goods_code = item['goods_code']
  206. material_detail.goods_name = item['goods_name']
  207. material_detail.goods_std = item['goods_std']
  208. material_detail.plan_qty = item['plan_qty']
  209. material_detail.goods_total_weight = item['plan_qty']
  210. material_detail.goods_unit = item['goods_unit']
  211. material_detail.note = (item['note'] if 'note' in item else '')
  212. material_detail.update_time = timezone.now()
  213. material_detail.save()
  214. except MaterialDetail.DoesNotExist:
  215. material_detail = MaterialDetail.objects.create(
  216. bound_billId=bound_bill,
  217. entryIds=item['entryIds'],
  218. production_batch=item['production_batch'],
  219. goods_code=item['goods_code'],
  220. goods_name=item['goods_name'],
  221. goods_std=item['goods_std'],
  222. goods_weight=1,
  223. plan_qty=item['plan_qty'],
  224. goods_total_weight=item['plan_qty'],
  225. goods_unit=item['goods_unit'],
  226. note=(item['note'] if 'note' in item else ''),
  227. create_time=timezone.now(),
  228. update_time=timezone.now()
  229. )
  230. return material_detail
  231. class GenerateInbound(APIView):
  232. """
  233. 生产入库单生成接口
  234. 功能特性:
  235. 1. 防重复创建校验(状态校验+关联单据校验)
  236. 2. 事务级数据一致性保障
  237. 3. 批量操作优化
  238. 4. 完整日志追踪
  239. """
  240. def post(self, request):
  241. try:
  242. bill_id = request.data.get('billId')
  243. if not bill_id:
  244. return Response({"error": "缺少必要参数: billId"},
  245. status=status.HTTP_400_BAD_REQUEST)
  246. # 开启原子事务
  247. with transaction.atomic():
  248. # 锁定原始单据并校验
  249. bill_obj, bound_list = self.validate_and_lock(bill_id)
  250. # 创建出入库主单
  251. bound_list = self.create_bound_list(bill_obj)
  252. logger.info(f"创建出入库主单成功 | bound_code: {bound_list.bound_code}")
  253. # 处理物料明细(批量操作优化)
  254. self.process_materials(bill_obj, bound_list)
  255. # 更新原始单据状态
  256. bill_obj.bound_status = 1
  257. bill_obj.save(update_fields=['bound_status'])
  258. logger.info(f"入库单生成成功 | billId: {bill_id} -> boundCode: {bound_list.bound_code}")
  259. return Response({
  260. "bound_code": bound_list.bound_code,
  261. "batch_count": bill_obj.totalCount
  262. }, status=status.HTTP_201_CREATED)
  263. except InboundBill.DoesNotExist:
  264. logger.error(f"原始单据不存在 | billId: {bill_id}")
  265. return Response({"error": "原始单据不存在"}, status=status.HTTP_404_NOT_FOUND)
  266. except Exception as e:
  267. logger.exception(f"入库单生成异常 | billId: {bill_id}")
  268. return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  269. def validate_and_lock(self, bill_id):
  270. """验证并锁定相关资源"""
  271. # 锁定原始单据
  272. bill_obj = InboundBill.objects.select_for_update().get(
  273. billId=bill_id,
  274. is_delete=False
  275. )
  276. # 状态校验
  277. if bill_obj.bound_status == 1:
  278. logger.warning(f"单据已生成过入库单 | status: {bill_obj.bound_status}")
  279. raise Exception("该单据已生成过入库单")
  280. # 关联单据校验(双重校验机制)
  281. existing_bound = BoundListModel.objects.filter(
  282. Q(bound_desc__contains=f"生产入库单{bill_obj.number}") |
  283. Q(relate_bill=bill_obj)
  284. ).first()
  285. if existing_bound:
  286. logger.warning(f"发现重复关联单据 | existingCode: {existing_bound.bound_code}")
  287. raise Exception(f"已存在关联入库单[{existing_bound.bound_code}]")
  288. return bill_obj, None
  289. def process_materials(self, bill_obj, bound_list):
  290. """批量处理物料明细"""
  291. materials = MaterialDetail.objects.filter(
  292. bound_billId=bill_obj,
  293. is_delete=False
  294. ).select_related('bound_billId')
  295. if not materials:
  296. raise Exception("入库单没有有效物料明细")
  297. # 批量创建对象列表
  298. batch_list = []
  299. detail_list = []
  300. log_list = []
  301. goods_counter = defaultdict(int)
  302. order_day=str(timezone.now().strftime('-%Y%m'))
  303. order_month=str(timezone.now().strftime('%Y%m'))
  304. for idx, material in enumerate(materials, 1):
  305. # 生成批次
  306. data = {}
  307. qs_set = BoundBatchModel.objects.filter( goods_code=material.goods_code, bound_month=order_month, is_delete=False)
  308. goods_code = material.goods_code
  309. goods_counter[goods_code] += 1
  310. len_qs_set = len(qs_set) + goods_counter[goods_code]
  311. print("len_qs_set", len_qs_set)
  312. data['bound_batch_order'] = int(order_day.split('-')[-1])*100 + len_qs_set
  313. data['bound_number'] = material.goods_code + order_day + str(len_qs_set).zfill(2)
  314. batch = BoundBatchModel(
  315. bound_number=data['bound_number'],
  316. bound_month=bound_list.bound_month,
  317. bound_batch_order=data['bound_batch_order'],
  318. warehouse_code='W01',
  319. warehouse_name='立体仓',
  320. goods_code=material.goods_code,
  321. goods_desc=material.goods_name,
  322. goods_std=material.goods_std,
  323. goods_unit=material.goods_unit,
  324. goods_qty=material.plan_qty,
  325. goods_weight=float(material.goods_weight),
  326. goods_total_weight=float(material.goods_total_weight),
  327. creater=bound_list.creater,
  328. openid=bound_list.openid,
  329. relate_material=material
  330. )
  331. batch_list.append(batch)
  332. # 生成明细
  333. detail_list.append(BoundDetailModel(
  334. bound_list=bound_list,
  335. bound_batch=batch,
  336. detail_code=f"{batch.bound_number}-DET",
  337. creater=bound_list.creater,
  338. openid=bound_list.openid
  339. ))
  340. # 生成日志
  341. log_list.append(BatchLogModel(
  342. batch_id=batch,
  343. log_type=0,
  344. log_date=timezone.now(),
  345. goods_code=batch.goods_code,
  346. goods_desc=batch.goods_desc,
  347. goods_qty=batch.goods_qty,
  348. log_content=f"生产入库批次创建,来源单据:{bill_obj.number}",
  349. creater=batch.creater,
  350. openid=batch.openid
  351. ))
  352. # 批量写入数据库
  353. BoundBatchModel.objects.bulk_create(batch_list)
  354. BoundDetailModel.objects.bulk_create(detail_list)
  355. BatchLogModel.objects.bulk_create(log_list)
  356. def create_bound_list(self, bill_obj):
  357. """创建出入库主单(带来源标识)"""
  358. if BoundListModel.objects.filter(relate_bill=bill_obj).exists():
  359. return BoundListModel.objects.get(relate_bill=bill_obj)
  360. return BoundListModel.objects.create(
  361. bound_month=timezone.now().strftime("%Y%m"),
  362. bound_date=timezone.now().strftime("%Y-%m-%d"),
  363. bound_code=bill_obj.number,
  364. bound_code_type=bill_obj.type,
  365. bound_bs_type='B01',
  366. bound_type='in',
  367. bound_desc=f"生产入库单{bill_obj.number}",
  368. bound_department=(bill_obj.department if bill_obj.department else 'D99'),
  369. base_type=0,
  370. bound_status='101',
  371. creater=bill_obj.creater,
  372. openid='ERP',
  373. relate_bill=bill_obj
  374. )
  375. def generate_sequence_code(self, prefix):
  376. """生成带顺序的编号(示例实现)"""
  377. timestamp = datetime.now().strftime("%Y%m%d%H%M")
  378. last_code = BoundListModel.objects.filter(
  379. bound_code__startswith=prefix
  380. ).order_by('-id').values_list('bound_code', flat=True).first()
  381. if last_code:
  382. seq = int(last_code[-3:]) + 1
  383. else:
  384. seq = 1
  385. return f"{prefix}{timestamp}-{seq:03d}"
  386. def handle_exception(self, exc):
  387. """统一异常处理"""
  388. if isinstance(exc, InboundBill.DoesNotExist):
  389. return Response({"error": "原始单据不存在或已被删除"}, status=status.HTTP_404_NOT_FOUND)
  390. elif "重复" in str(exc):
  391. return Response({"error": str(exc)}, status=status.HTTP_409_CONFLICT)
  392. return super().handle_exception(exc)
  393. class OutboundApplyCreate(APIView):
  394. """
  395. 生产出库申请
  396. """
  397. authentication_classes = [] # 禁用所有认证类
  398. permission_classes = [AllowAny] # 允许任意访问
  399. def post(self, request):
  400. return Response(status=status.HTTP_200_OK)
  401. class BatchUpdate(APIView):
  402. """
  403. 批次信息更新
  404. """
  405. authentication_classes = [] # 禁用所有认证类
  406. permission_classes = [AllowAny] # 允许任意访问
  407. def post(self, request):
  408. logger.info('批次信息更新')
  409. serializer = BatchUpdateSerializer(data=request.data)
  410. return Response(serializer.data, status=status.HTTP_200_OK)
  411. class ProductInfo(APIView):
  412. """
  413. 商品信息查询
  414. """
  415. authentication_classes = [] # 禁用所有认证类
  416. permission_classes = [AllowAny] # 允许任意访问
  417. def post(self, request):
  418. logger.info('商品信息查询')
  419. serializer = ProductInfoSerializer(data=request.data)
  420. return Response(serializer.data, status=status.HTTP_200_OK)
  421. class InboundBills(viewsets.ModelViewSet):
  422. """
  423. retrieve:
  424. Response a data list(get)
  425. list:
  426. Response a data list(all)
  427. """
  428. # authentication_classes = [] # 禁用所有认证类
  429. # permission_classes = [AllowAny] # 允许任意访问
  430. pagination_class = MyPageNumberPagination
  431. filter_backends = [DjangoFilterBackend, OrderingFilter, ]
  432. ordering_fields = ["update_time", "create_time"]
  433. filter_class = InboundBillFilter
  434. def get_project(self):
  435. # 获取项目ID,如果不存在则返回None
  436. try:
  437. id = self.kwargs.get('pk')
  438. return id
  439. except:
  440. return None
  441. def get_queryset(self):
  442. # 根据请求用户过滤查询集
  443. id = self.get_project()
  444. if self.request.user:
  445. if id is None:
  446. return InboundBill.objects.filter(is_delete=False)
  447. else:
  448. return InboundBill.objects.filter(billId=id, is_delete=False)
  449. else:
  450. return InboundBill.objects.none()
  451. def get_serializer_class(self):
  452. # 根据操作类型选择合适的序列化器
  453. if self.action in ['list', 'retrieve', ]:
  454. return InboundApplySerializer
  455. else:
  456. return self.http_method_not_allowed(request=self.request)
  457. class Materials(viewsets.ModelViewSet):
  458. """
  459. retrieve:
  460. Response a data list(get)
  461. list:
  462. Response a data list(all)
  463. """
  464. # authentication_classes = [] # 禁用所有认证类
  465. # permission_classes = [AllowAny] # 允许任意访问
  466. pagination_class = MyPageNumberPagination
  467. filter_backends = [DjangoFilterBackend, OrderingFilter, ]
  468. ordering_fields = ['id', "create_time", "update_time", ]
  469. filter_class = MaterialDetailFilter
  470. def get_project(self):
  471. try:
  472. id = self.kwargs.get('pk')
  473. return id
  474. except:
  475. return None
  476. def get_queryset(self):
  477. id = self.get_project()
  478. if self.request.user:
  479. if id is None:
  480. return MaterialDetail.objects.filter(is_delete=False)
  481. else:
  482. return MaterialDetail.objects.filter(id=id)
  483. else:
  484. return MaterialDetail.objects.none()
  485. def get_serializer_class(self):
  486. if self.action in ['retrieve', 'list']:
  487. return MaterialDetailSerializer
  488. else:
  489. return self.http_method_not_allowed(request=self.request)