forked from erp-dev/erp
feat: external_order refetch api
This commit is contained in:
249
api_v2/test_external_printing_order_snapshot_sync_api.py
Normal file
249
api_v2/test_external_printing_order_snapshot_sync_api.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api_v1.tasks import ExternalPrintingOrderSnapshotNotFoundError
|
||||
from api_v1.views.printing.services import PrintingJobService
|
||||
from basic_info import models as basic_models
|
||||
from printing import models as printing_models
|
||||
from shipment import models as shipment_models
|
||||
from stateflow import models as stateflow_models
|
||||
from stateflow import services as stateflow_services
|
||||
|
||||
|
||||
class PrintingOrderExternalSnapshotSyncAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='印染工厂',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username='factory-sync-user',
|
||||
password='pass12345',
|
||||
)
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='操作员甲',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='鸿烨服饰',
|
||||
)
|
||||
self.category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='同步产品类',
|
||||
product_prefix='TP',
|
||||
)
|
||||
self.product_keep = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='Tj1712#12号色-24码',
|
||||
)
|
||||
self.product_stale = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='Tj1712#12号色-25码',
|
||||
)
|
||||
|
||||
state = stateflow_models.State.objects.create(name='待生产')
|
||||
self.process = stateflow_models.Process.objects.create(name='默认印染流程')
|
||||
self.process.replace_nodes([state])
|
||||
|
||||
settings.PRINTING_DEFAULT_PROCESS_ID = self.process.id
|
||||
settings.PRINTING_EXTERNAL_SYNC_USER_ID = self.user.id
|
||||
settings.PRINTING_EXTERNAL_SYNC_PRODUCT_CATEGORY_ID = self.category.id
|
||||
|
||||
def _create_printing_order(self, *, external_order_id='KD20432358'):
|
||||
return printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
fabric='旧布料',
|
||||
width='150cm',
|
||||
area='旧地区',
|
||||
process=self.process,
|
||||
external_order_id=external_order_id,
|
||||
)
|
||||
|
||||
def _create_job(self, *, printing_order, product, quantity=10):
|
||||
return PrintingJobService.create_printing_job(
|
||||
{
|
||||
'printing_order': printing_order,
|
||||
'product': product,
|
||||
'quantity': quantity,
|
||||
'unit': '段',
|
||||
'size': '2.68',
|
||||
'pieces': 10,
|
||||
'description': None,
|
||||
'original_id': None,
|
||||
'external_product_name': None,
|
||||
'external_raw': {},
|
||||
},
|
||||
self.user,
|
||||
)
|
||||
|
||||
def _build_snapshot_payload(self, *, external_order_id='KD20432358', records=None):
|
||||
return {
|
||||
'mode': 'snapshot',
|
||||
'external_order_id': external_order_id,
|
||||
'status': 'active',
|
||||
'snapshot_at': '2026-04-16T10:12:34Z',
|
||||
'total_count': len(records or []),
|
||||
'records': records or [],
|
||||
}
|
||||
|
||||
def _build_record(self, *, record_id=1000001, external_order_id='KD20432358', product_name=None, quantity='10.00'):
|
||||
return {
|
||||
'BeiZhu': '手感一定要柔软',
|
||||
'BeiZhuC': '10件',
|
||||
'BianHaoID': external_order_id,
|
||||
'BianHaoKD': '1.27',
|
||||
'CaoZY': '操作员甲',
|
||||
'FidJ': r'\\fw\\2026年-LWQ15\\2026\\H鸿烨\\Tj1712#',
|
||||
'HpName': '120克本白四面弹单定',
|
||||
'ID': record_id,
|
||||
'JiJiaDW': '/段',
|
||||
'KdRiQi': '2026-01-26T20:00:52Z',
|
||||
'KhID': 'KH00999',
|
||||
'MeoA': 'LWQ15',
|
||||
'RiQi': '2026-01-26T00:00:00Z',
|
||||
'SHDZ': '客户布 烧花',
|
||||
'SeHao': '1.51',
|
||||
'ShuLiang': quantity,
|
||||
'ShuLiangZ': '2.68',
|
||||
'YanSe': product_name or self.product_keep.name,
|
||||
'area': '周边',
|
||||
'customer': {
|
||||
'KhID': 'KH00999',
|
||||
'KhName': self.customer.name,
|
||||
},
|
||||
}
|
||||
|
||||
def test_sync_fails_early_when_order_has_active_sales_items_and_records_audit(self):
|
||||
order = self._create_printing_order()
|
||||
job = self._create_job(printing_order=order, product=self.product_keep)
|
||||
sales_item = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='销售品A',
|
||||
quantity='10.00',
|
||||
unit=shipment_models.UnitChoices.PIECE,
|
||||
created_by=self.user,
|
||||
printing_job_id=job.id,
|
||||
customer_id=self.customer.id,
|
||||
)
|
||||
|
||||
with patch('api_v1.tasks._fetch_external_printing_order_snapshot') as mock_fetch:
|
||||
resp = self.client.post(
|
||||
'/api/v2/printing-orders/sync-external-snapshot/',
|
||||
{'external_order_id': order.external_order_id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 409)
|
||||
self.assertIn('销售品', resp.data['detail'])
|
||||
self.assertIsNotNone(resp.data['audit_id'])
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id'])
|
||||
self.assertFalse(audit.is_success)
|
||||
self.assertIn(str(sales_item.id), audit.failure_reason)
|
||||
self.assertEqual(audit.operator_user, self.user)
|
||||
self.assertEqual(audit.operator_employee, self.employee)
|
||||
self.assertEqual(audit.before_snapshot['printing_order']['id'], order.id)
|
||||
self.assertEqual(len(audit.before_snapshot['printing_jobs']), 1)
|
||||
|
||||
def test_sync_fails_when_started_jobs_exist_and_allow_reset_stateflow_is_false(self):
|
||||
order = self._create_printing_order()
|
||||
job = self._create_job(printing_order=order, product=self.product_keep)
|
||||
ok, _msg, _state_log = stateflow_services.advance_to_next_state(job.business_object, self.user)
|
||||
self.assertTrue(ok)
|
||||
|
||||
with patch('api_v1.tasks._fetch_external_printing_order_snapshot') as mock_fetch:
|
||||
resp = self.client.post(
|
||||
'/api/v2/printing-orders/sync-external-snapshot/',
|
||||
{'external_order_id': order.external_order_id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 409)
|
||||
self.assertIn('allow_reset_stateflow=true', resp.data['detail'])
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id'])
|
||||
self.assertFalse(audit.is_success)
|
||||
self.assertIn(str(job.id), audit.failure_reason)
|
||||
|
||||
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
||||
def test_sync_successfully_updates_order_jobs_and_resets_stateflow_when_allowed(self, mock_fetch_snapshot):
|
||||
order = self._create_printing_order()
|
||||
keep_job = self._create_job(printing_order=order, product=self.product_keep, quantity=10)
|
||||
stale_job = self._create_job(printing_order=order, product=self.product_stale, quantity=20)
|
||||
|
||||
ok, _msg, _state_log = stateflow_services.advance_to_next_state(keep_job.business_object, self.user)
|
||||
self.assertTrue(ok)
|
||||
|
||||
mock_fetch_snapshot.return_value = self._build_snapshot_payload(
|
||||
records=[
|
||||
self._build_record(
|
||||
record_id=1000008,
|
||||
product_name=self.product_keep.name,
|
||||
quantity='25.00',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
'/api/v2/printing-orders/sync-external-snapshot/',
|
||||
{
|
||||
'external_order_id': order.external_order_id,
|
||||
'allow_reset_stateflow': True,
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['orders_updated'], 1)
|
||||
self.assertEqual(resp.data['jobs_updated'], 1)
|
||||
self.assertEqual(resp.data['jobs_deleted'], 1)
|
||||
self.assertEqual(resp.data['reset_stateflow_job_count'], 1)
|
||||
|
||||
order.refresh_from_db()
|
||||
keep_job.refresh_from_db()
|
||||
self.assertEqual(order.fabric, '120克本白四面弹单定')
|
||||
self.assertEqual(order.area, '周边')
|
||||
self.assertEqual(keep_job.quantity, 25)
|
||||
self.assertEqual(keep_job.original_id, 1000008)
|
||||
self.assertFalse(printing_models.PrintingJob.objects.filter(id=stale_job.id).exists())
|
||||
self.assertEqual(keep_job.business_object.state_logs.filter(is_cancelled=False).count(), 0)
|
||||
|
||||
audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id'])
|
||||
self.assertTrue(audit.is_success)
|
||||
self.assertEqual(audit.printing_order_id, order.id)
|
||||
self.assertTrue(audit.allow_reset_stateflow)
|
||||
self.assertEqual(audit.before_snapshot['printing_order']['fabric'], '旧布料')
|
||||
self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2)
|
||||
|
||||
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
||||
def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot):
|
||||
mock_fetch_snapshot.side_effect = ExternalPrintingOrderSnapshotNotFoundError('未找到该 external_order_id 对应的订单')
|
||||
|
||||
resp = self.client.post(
|
||||
'/api/v2/printing-orders/sync-external-snapshot/',
|
||||
{'external_order_id': 'KD-NOT-FOUND'},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
self.assertIn('未找到', resp.data['detail'])
|
||||
|
||||
audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id'])
|
||||
self.assertFalse(audit.is_success)
|
||||
self.assertEqual(audit.external_order_id, 'KD-NOT-FOUND')
|
||||
self.assertEqual(audit.before_snapshot['exists'], False)
|
||||
@@ -9,6 +9,7 @@ from api_v2.views import (
|
||||
PrintingJobBatchAdvancePreviewView,
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PrintingJobBatchAddParametersView,
|
||||
PrintingOrderExternalSnapshotSyncView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
PlateOrderByStateStatusView,
|
||||
@@ -40,6 +41,7 @@ urlpatterns = [
|
||||
path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'),
|
||||
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
|
||||
path('printing-jobs/batch-add-parameters/', PrintingJobBatchAddParametersView.as_view(), name='api_v2_printing_job_batch_add_parameters'),
|
||||
path('printing-orders/sync-external-snapshot/', PrintingOrderExternalSnapshotSyncView.as_view(), name='api_v2_printing_order_sync_external_snapshot'),
|
||||
path('printing-orders/<int:printing_order_id>/batch-advance-records/', PrintingOrderBatchAdvanceRecordsView.as_view(), name='api_v2_printing_order_batch_advance_records'),
|
||||
path('plate-orders/by-process-node/', PlateOrderByProcessNodeView.as_view(), name='api_v2_plate_order_by_process_node'),
|
||||
path('plate-orders/by-process/', PlateOrderByProcessView.as_view(), name='api_v2_plate_order_by_process'),
|
||||
|
||||
@@ -10,6 +10,7 @@ from .printing import (
|
||||
PrintingJobBatchAdvancePreviewView,
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PrintingJobBatchAddParametersView,
|
||||
PrintingOrderExternalSnapshotSyncView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
PlateOrderByStateStatusView,
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
'PrintingJobBatchAdvancePreviewView',
|
||||
'PrintingJobBatchAdvanceSubmitView',
|
||||
'PrintingJobBatchAddParametersView',
|
||||
'PrintingOrderExternalSnapshotSyncView',
|
||||
'PlateOrderByProcessNodeView',
|
||||
'PlateOrderByProcessView',
|
||||
'PlateOrderByStateStatusView',
|
||||
|
||||
@@ -13,6 +13,10 @@ from rest_framework.views import APIView
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from printing import models as printing_models
|
||||
from api_v1.tasks import (
|
||||
ExternalPrintingOrderSnapshotSyncError,
|
||||
sync_external_printing_order_snapshot_impl,
|
||||
)
|
||||
from api_man.serializers import ProductSerializer
|
||||
from api_v1.views.printing.serializers import (
|
||||
PlateOrderListSerializer as PlateOrderListV1Serializer,
|
||||
@@ -1529,6 +1533,63 @@ class PrintingOrderBatchAdvanceRecordsView(APIView):
|
||||
})
|
||||
|
||||
|
||||
class PrintingOrderExternalSnapshotSyncRequestSerializer(serializers.Serializer):
|
||||
external_order_id = serializers.CharField(max_length=100, help_text='外部订单编号')
|
||||
allow_reset_stateflow = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text='是否允许先撤销目标订单下已有的所有工序,再执行覆盖同步',
|
||||
)
|
||||
|
||||
def validate_external_order_id(self, value):
|
||||
normalized = str(value or '').strip()
|
||||
if not normalized:
|
||||
raise serializers.ValidationError('external_order_id 不能为空')
|
||||
return normalized
|
||||
|
||||
|
||||
class PrintingOrderExternalSnapshotSyncView(APIView):
|
||||
"""
|
||||
按 external_order_id 触发外部订单快照同步。
|
||||
|
||||
行为:
|
||||
- 若目标订单任意 printing_job 已关联销售品,则立即失败并记录审计
|
||||
- 若目标订单存在已执行工序,默认失败;可通过 allow_reset_stateflow=true 先撤销全部工序后再覆盖
|
||||
- 成功时复用现有外部订单映射逻辑覆盖 PrintingOrder / PrintingJob
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
|
||||
|
||||
def post(self, request):
|
||||
srz = PrintingOrderExternalSnapshotSyncRequestSerializer(data=request.data)
|
||||
srz.is_valid(raise_exception=True)
|
||||
|
||||
payload = srz.validated_data
|
||||
try:
|
||||
result = sync_external_printing_order_snapshot_impl(
|
||||
external_order_id=payload['external_order_id'],
|
||||
operator_user=request.user,
|
||||
allow_reset_stateflow=payload.get('allow_reset_stateflow', False),
|
||||
)
|
||||
except ExternalPrintingOrderSnapshotSyncError as exc:
|
||||
return Response(
|
||||
{
|
||||
'detail': str(exc),
|
||||
'audit_id': exc.audit_id,
|
||||
'external_order_id': payload['external_order_id'],
|
||||
},
|
||||
status=exc.status_code,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'detail': '外部订单快照同步成功',
|
||||
**result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class PrintingJobBatchAddParametersRequestSerializer(serializers.Serializer):
|
||||
"""批量补充参数请求序列化器"""
|
||||
printing_job_ids = serializers.ListField(
|
||||
|
||||
Reference in New Issue
Block a user