1
0
forked from erp-dev/erp

feat: external_order refetch api

This commit is contained in:
2026-04-16 17:34:16 +08:00
parent 4f3109546d
commit e109c00837
9 changed files with 1547 additions and 0 deletions

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

View File

@@ -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'),

View File

@@ -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',

View File

@@ -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(