diff --git a/api_v1/tasks.py b/api_v1/tasks.py index f57e4ab..afd5c51 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -16,6 +16,7 @@ import requests from celery import shared_task from django.conf import settings from django.contrib.auth import get_user_model +from django.db import transaction from django.core.files.base import ContentFile from django.utils import timezone @@ -39,6 +40,23 @@ logger = logging.getLogger(__name__) User = get_user_model() +class ExternalPrintingOrderSnapshotSyncError(RuntimeError): + def __init__(self, message: str, *, status_code: int = 400, audit_id: int | None = None): + super().__init__(message) + self.status_code = status_code + self.audit_id = audit_id + + +class ExternalPrintingOrderSnapshotNotFoundError(ExternalPrintingOrderSnapshotSyncError): + def __init__(self, message: str, *, audit_id: int | None = None): + super().__init__(message, status_code=404, audit_id=audit_id) + + +class ExternalPrintingOrderSnapshotConflictError(ExternalPrintingOrderSnapshotSyncError): + def __init__(self, message: str, *, audit_id: int | None = None): + super().__init__(message, status_code=409, audit_id=audit_id) + + def _ensure_backup_dir(output_dir: str | None) -> Path: base_dir = Path(settings.BASE_DIR) backup_dir = Path(output_dir) if output_dir else (base_dir / 'data-bak') @@ -308,6 +326,305 @@ def _advance_external_printing_cursor(*, cursor_value: int) -> dict: return payload +def _fetch_external_printing_order_snapshot(*, external_order_id: str) -> dict: + normalized = str(external_order_id or '').strip() + if not normalized: + raise ExternalPrintingOrderSnapshotSyncError('external_order_id 不能为空', status_code=400) + + url = _build_external_url('/api/v1/records/by-order') + try: + response = requests.get( + url, + headers=_get_external_headers(), + params={'external_order_id': normalized}, + timeout=30, + ) + except requests.RequestException as exc: + raise ExternalPrintingOrderSnapshotSyncError( + f'请求外部订单快照接口失败: {exc}', + status_code=502, + ) from exc + + if response.status_code == 404: + message = f'未找到 external_order_id={normalized} 对应的外部订单' + try: + payload = response.json() + except ValueError: + payload = {} + if isinstance(payload, dict): + message = str(payload.get('message') or message) + raise ExternalPrintingOrderSnapshotNotFoundError(message) + + if response.status_code >= 400: + message = f'外部订单快照接口返回异常状态码: {response.status_code}' + try: + payload = response.json() + except ValueError: + payload = {} + if isinstance(payload, dict): + message = str(payload.get('message') or payload.get('error') or message) + raise ExternalPrintingOrderSnapshotSyncError(message, status_code=502) + + try: + payload = response.json() + except ValueError as exc: + raise ExternalPrintingOrderSnapshotSyncError( + '外部订单快照接口返回格式非法:非 JSON object', + status_code=502, + ) from exc + + if not isinstance(payload, dict): + raise ExternalPrintingOrderSnapshotSyncError( + '外部订单快照接口返回格式非法:非 JSON object', + status_code=502, + ) + return payload + + +def _serialize_datetime_for_snapshot(value): + if value is None: + return None + if hasattr(value, 'isoformat'): + return value.isoformat() + return str(value) + + +def _get_external_printing_order_for_sync(*, merchant, external_order_id: str): + return ( + printing_models.PrintingOrder.objects.filter( + merchant=merchant, + external_order_id=external_order_id, + ) + .select_related('customer', 'process', 'created_by') + .prefetch_related('printing_jobs__product', 'printing_jobs__business_object') + .order_by('id') + .first() + ) + + +def _build_printing_order_before_snapshot(printing_order): + if not printing_order: + return {'exists': False, 'printing_order': None, 'printing_jobs': []} + + jobs = list( + printing_order.printing_jobs.select_related('product', 'business_object').order_by('id') + ) + return { + 'exists': True, + 'printing_order': { + 'id': printing_order.id, + 'human_id': printing_order.human_id, + 'customer_id': printing_order.customer_id, + 'customer_name': getattr(printing_order.customer, 'name', None), + 'fabric': printing_order.fabric, + 'width': printing_order.width, + 'is_urgent': printing_order.is_urgent, + 'area': printing_order.area, + 'address': printing_order.address, + 'fabric_source': printing_order.fabric_source, + 'is_fabric_received': printing_order.is_fabric_received, + 'craft': printing_order.craft, + 'description': printing_order.description, + 'outgoing_date': _serialize_datetime_for_snapshot(printing_order.outgoing_date), + 'curve': printing_order.curve, + 'new_curve': printing_order.new_curve, + 'position': printing_order.position, + 'printing_warn': printing_order.printing_warn, + 'rolling_warn': printing_order.rolling_warn, + 'production_warn': printing_order.production_warn, + 'print_count': printing_order.print_count, + 'is_invalid': printing_order.is_invalid, + 'process_id': printing_order.process_id, + 'created_by_id': printing_order.created_by_id, + 'external_order_id': printing_order.external_order_id, + 'external_customer_id': printing_order.external_customer_id, + 'external_customer_name': printing_order.external_customer_name, + 'external_employee_name': printing_order.external_employee_name, + 'external_raw': printing_order.external_raw, + 'created_at': _serialize_datetime_for_snapshot(printing_order.created_at), + 'updated_at': _serialize_datetime_for_snapshot(printing_order.updated_at), + }, + 'printing_jobs': [ + { + 'id': job.id, + 'original_id': job.original_id, + 'product_id': job.product_id, + 'product_name': getattr(job.product, 'name', None), + 'quantity': job.quantity, + 'unit': job.unit, + 'size': job.size, + 'pieces': job.pieces, + 'description': job.description, + 'work_state': job.work_state, + 'is_production_completed': job.is_production_completed, + 'business_object_id': job.business_object_id, + 'created_by_id': job.created_by_id, + 'external_product_name': job.external_product_name, + 'external_raw': job.external_raw, + 'created_at': _serialize_datetime_for_snapshot(job.created_at), + 'updated_at': _serialize_datetime_for_snapshot(job.updated_at), + } + for job in jobs + ], + } + + +def _create_printing_order_snapshot_sync_audit(*, printing_order, external_order_id: str, operator_user, allow_reset_stateflow: bool): + operator_employee = getattr(operator_user, 'employee', None) + return printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.create( + printing_order=printing_order, + external_order_id=external_order_id, + operator_user=operator_user, + operator_employee=operator_employee, + before_snapshot=_build_printing_order_before_snapshot(printing_order), + allow_reset_stateflow=allow_reset_stateflow, + is_success=False, + failure_reason='', + ) + + +def _mark_printing_order_snapshot_sync_audit_failure(audit, reason: str, *, printing_order=None): + if not audit: + return + audit.printing_order = printing_order or audit.printing_order + audit.is_success = False + audit.failure_reason = str(reason or '').strip() + audit.save(update_fields=['printing_order', 'is_success', 'failure_reason', 'updated_at']) + + +def _mark_printing_order_snapshot_sync_audit_success(audit, *, printing_order): + if not audit: + return + audit.printing_order = printing_order + audit.is_success = True + audit.failure_reason = '' + audit.save(update_fields=['printing_order', 'is_success', 'failure_reason', 'updated_at']) + + +def _get_printing_order_active_sales_item_info(printing_order): + from shipment.services import get_active_sales_items_queryset + + job_ids = list(printing_order.printing_jobs.values_list('id', flat=True)) + if not job_ids: + return {'job_ids': [], 'sales_item_ids': []} + + sales_items = list( + get_active_sales_items_queryset() + .filter(printing_job_id__in=job_ids) + .values('id', 'printing_job_id') + .order_by('id') + ) + return { + 'job_ids': sorted({item['printing_job_id'] for item in sales_items if item['printing_job_id']}), + 'sales_item_ids': [item['id'] for item in sales_items], + } + + +def _get_printing_order_started_job_ids(printing_order): + started_job_ids = [] + for job in printing_order.printing_jobs.select_related('business_object').all(): + business_object = getattr(job, 'business_object', None) + if business_object and business_object.state_logs.filter(is_cancelled=False).exists(): + started_job_ids.append(job.id) + return started_job_ids + + +def _validate_external_printing_order_snapshot_payload(*, payload: dict, external_order_id: str) -> list[dict]: + if str(payload.get('mode') or '').strip() != 'snapshot': + raise ExternalPrintingOrderSnapshotSyncError('外部订单快照接口返回的 mode 非 snapshot', status_code=502) + + payload_external_order_id = str(payload.get('external_order_id') or '').strip() + if payload_external_order_id != external_order_id: + raise ExternalPrintingOrderSnapshotSyncError( + f'外部订单快照接口返回的 external_order_id 不匹配: {payload_external_order_id or ""}', + status_code=502, + ) + + status_value = str(payload.get('status') or '').strip() + if status_value and status_value != 'active': + raise ExternalPrintingOrderSnapshotSyncError( + f'外部订单快照状态不支持同步: {status_value}', + status_code=409, + ) + + records = payload.get('records', []) + if not isinstance(records, list): + raise ExternalPrintingOrderSnapshotSyncError('外部订单快照接口返回格式非法:records 不是数组', status_code=502) + if not records: + raise ExternalPrintingOrderSnapshotSyncError('外部订单快照接口返回空 records,无法执行覆盖同步', status_code=409) + + for record in records: + if not isinstance(record, dict): + raise ExternalPrintingOrderSnapshotSyncError('外部订单快照接口返回格式非法:record 不是对象', status_code=502) + record_external_order_id = str(record.get('BianHaoID') or '').strip() + if record_external_order_id != external_order_id: + raise ExternalPrintingOrderSnapshotSyncError( + f'外部订单快照中存在不属于目标订单的记录: {record_external_order_id or ""}', + status_code=502, + ) + return records + + +def _reset_printing_order_stateflow_progress(printing_order) -> int: + from stateflow.services import reset_business_object_progress + + reset_count = 0 + for job in printing_order.printing_jobs.select_related('business_object').all(): + business_object = getattr(job, 'business_object', None) + if not business_object: + continue + if business_object.state_logs.filter(is_cancelled=False).exists(): + reset_business_object_progress(business_object) + reset_count += 1 + return reset_count + + +def _delete_stale_printing_jobs_for_external_snapshot(*, printing_order, keep_job_ids: set[int]) -> int: + from business.models import SalesOrderItem + + stale_jobs = list( + printing_order.printing_jobs.select_related('business_object').exclude(id__in=keep_job_ids).order_by('id') + ) + if not stale_jobs: + return 0 + + stale_job_ids = [job.id for job in stale_jobs] + active_sales_items = _get_printing_order_active_sales_item_info(printing_order) + blocked_sales_item_job_ids = [job_id for job_id in active_sales_items['job_ids'] if job_id in stale_job_ids] + if blocked_sales_item_job_ids: + raise ExternalPrintingOrderSnapshotConflictError( + '以下待删除 printing_job 已关联销售品,禁止覆盖同步: ' + f'{blocked_sales_item_job_ids}' + ) + + blocked_sales_order_job_ids = list( + SalesOrderItem.objects.filter(printing_job_id__in=stale_job_ids) + .order_by('printing_job_id') + .values_list('printing_job_id', flat=True) + .distinct() + ) + if blocked_sales_order_job_ids: + raise ExternalPrintingOrderSnapshotConflictError( + '以下待删除 printing_job 已关联销售单明细,禁止覆盖同步: ' + f'{blocked_sales_order_job_ids}' + ) + + deleted_count = 0 + for job in stale_jobs: + business_object = getattr(job, 'business_object', None) + has_logs = bool( + business_object and business_object.state_logs.exists() + ) + if business_object: + job.business_object = None + job.save(update_fields=['business_object']) + job.delete() + if business_object and not has_logs: + business_object.delete() + deleted_count += 1 + return deleted_count + + def _group_external_records(records: list[dict]) -> dict[str, list[dict]]: grouped: dict[str, list[dict]] = defaultdict(list) for record in records: @@ -767,6 +1084,134 @@ def _sync_external_printing_records_batch( } +def sync_external_printing_order_snapshot_impl( + *, + external_order_id: str, + operator_user, + allow_reset_stateflow: bool = False, +) -> dict: + normalized_external_order_id = str(external_order_id or '').strip() + if not normalized_external_order_id: + raise ExternalPrintingOrderSnapshotSyncError('external_order_id 不能为空', status_code=400) + + operator_employee = getattr(operator_user, 'employee', None) + merchant = getattr(operator_employee, 'merchant', None) + if operator_employee is None or merchant is None: + raise ExternalPrintingOrderSnapshotSyncError('当前用户未绑定 employee.merchant,无法执行同步', status_code=403) + + existing_order = _get_external_printing_order_for_sync( + merchant=merchant, + external_order_id=normalized_external_order_id, + ) + audit = _create_printing_order_snapshot_sync_audit( + printing_order=existing_order, + external_order_id=normalized_external_order_id, + operator_user=operator_user, + allow_reset_stateflow=allow_reset_stateflow, + ) + + try: + if existing_order: + active_sales_item_info = _get_printing_order_active_sales_item_info(existing_order) + if active_sales_item_info['sales_item_ids']: + raise ExternalPrintingOrderSnapshotConflictError( + '目标订单存在已关联销售品的 printing_job,禁止覆盖同步。' + f" printing_job_ids={active_sales_item_info['job_ids']}," + f" sales_item_ids={active_sales_item_info['sales_item_ids']}", + audit_id=audit.id, + ) + + started_job_ids = _get_printing_order_started_job_ids(existing_order) + if started_job_ids and not allow_reset_stateflow: + raise ExternalPrintingOrderSnapshotConflictError( + '目标订单存在已执行工序的 printing_job,默认不允许覆盖同步。' + ' 如确认要覆盖,请传 allow_reset_stateflow=true。' + f' printing_job_ids={started_job_ids}', + audit_id=audit.id, + ) + + snapshot_payload = _fetch_external_printing_order_snapshot( + external_order_id=normalized_external_order_id, + ) + records = _validate_external_printing_order_snapshot_payload( + payload=snapshot_payload, + external_order_id=normalized_external_order_id, + ) + sync_user = _get_printing_sync_user() + category = _get_printing_sync_product_category(merchant) + + with transaction.atomic(): + reset_stateflow_job_count = 0 + if existing_order and allow_reset_stateflow: + reset_stateflow_job_count = _reset_printing_order_stateflow_progress(existing_order) + + printing_order, order_created = _upsert_external_printing_order( + merchant=merchant, + external_order_id=normalized_external_order_id, + group_records=records, + sync_user=sync_user, + ) + + jobs_created = 0 + jobs_updated = 0 + kept_job_ids: set[int] = set() + for record in records: + job, job_created = _upsert_external_printing_job( + merchant=merchant, + printing_order=printing_order, + record=record, + sync_user=sync_user, + category=category, + ) + kept_job_ids.add(job.id) + if job_created: + jobs_created += 1 + else: + jobs_updated += 1 + + jobs_deleted = _delete_stale_printing_jobs_for_external_snapshot( + printing_order=printing_order, + keep_job_ids=kept_job_ids, + ) + + _mark_printing_order_snapshot_sync_audit_success( + audit, + printing_order=printing_order, + ) + return { + 'audit_id': audit.id, + 'external_order_id': normalized_external_order_id, + 'printing_order_id': printing_order.id, + 'orders_created': 1 if order_created else 0, + 'orders_updated': 0 if order_created else 1, + 'jobs_created': jobs_created, + 'jobs_updated': jobs_updated, + 'jobs_deleted': jobs_deleted, + 'reset_stateflow_job_count': reset_stateflow_job_count, + } + except ExternalPrintingOrderSnapshotSyncError as exc: + if exc.audit_id is None: + exc.audit_id = audit.id + _mark_printing_order_snapshot_sync_audit_failure( + audit, + str(exc), + printing_order=existing_order, + ) + raise + except Exception as exc: + reason = f'外部订单快照同步失败: {exc}' + _mark_printing_order_snapshot_sync_audit_failure( + audit, + reason, + printing_order=existing_order, + ) + raise ExternalPrintingOrderSnapshotSyncError( + reason, + status_code=500, + audit_id=audit.id, + ) from exc + + def retry_external_printing_sync_failures_impl( *, limit: int | None = None, diff --git a/api_v2/test_external_printing_order_snapshot_sync_api.py b/api_v2/test_external_printing_order_snapshot_sync_api.py new file mode 100644 index 0000000..5e587c0 --- /dev/null +++ b/api_v2/test_external_printing_order_snapshot_sync_api.py @@ -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) \ No newline at end of file diff --git a/api_v2/urls.py b/api_v2/urls.py index 8bd99ea..a9e5bb5 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -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//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'), diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index 2e3c028..c0d2b79 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -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', diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index d66b13a..4ec18ac 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -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( diff --git a/docs/api_v2_printing_order_sync_external_snapshot.md b/docs/api_v2_printing_order_sync_external_snapshot.md new file mode 100644 index 0000000..e896573 --- /dev/null +++ b/docs/api_v2_printing_order_sync_external_snapshot.md @@ -0,0 +1,320 @@ +# 按 external_order_id 同步外部订单快照 API + +## 概述 + +用于前端按 `external_order_id` 手动触发一次“外部订单快照重拉并覆盖更新本地数据”的操作。 + +- **端点**: `POST /api/v2/printing-orders/sync-external-snapshot/` +- **认证**: 需要登录 +- **权限**: 印染工厂用户 + +此接口适用于以下场景: + +- 外部订单已同步过一次,但后续外部系统又修改了订单数据 +- 人工发现本地 `PrintingOrder` / `PrintingJob` 与外部系统不一致 +- 需要按单号重新拉取该订单当前完整快照并覆盖到本地 + +## 同步行为说明 + +接口会按以下顺序执行: + +1. 根据 `external_order_id` 查找本地目标 `PrintingOrder` +2. 先写入一条同步审计记录,保存同步前本地快照 +3. 做覆盖前检查 +4. 调用外部快照接口拉取该订单当前完整 `records` +5. 复用现有外部同步映射逻辑,更新或创建本地 `PrintingOrder` / `PrintingJob` +6. 删除“本地存在但外部快照已不存在”的旧 `PrintingJob`(仅在安全条件满足时) + +## 覆盖前检查规则 + +### 1. 关联销售品时立即失败 + +如果目标 `PrintingOrder` 下任意 `PrintingJob` 已关联销售品,则接口会立即失败,不会请求外部快照接口。 + +失败时会: + +- 返回 `409 Conflict` +- 写入失败审计记录 +- 在失败原因中明确给出涉及的 `printing_job_ids` 和 `sales_item_ids` + +### 2. 已有工序记录时默认失败 + +如果目标 `PrintingOrder` 下存在任意 `PrintingJob` 已有未撤销的工序记录,则默认失败。 + +失败时会: + +- 返回 `409 Conflict` +- 写入失败审计记录 +- 提示前端可使用 `allow_reset_stateflow=true` 重试 + +### 3. `allow_reset_stateflow=true` 的含义 + +当请求体传入: + +```json +{ + "allow_reset_stateflow": true +} +``` + +接口会先撤销目标订单下所有 `PrintingJob` 的未撤销工序记录,然后再执行覆盖同步。 + +注意: + +- 默认值是 `false` +- 只有在“没有销售品关联”的前提下才会继续执行 +- 该撤销操作只处理工序状态,不会保留当前流程执行进度 + +## 请求 + +### 请求体 + +```json +{ + "external_order_id": "KD20432358", + "allow_reset_stateflow": false +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `external_order_id` | string | 是 | - | 外部订单号 | +| `allow_reset_stateflow` | boolean | 否 | `false` | 是否允许先撤销目标订单下已有工序再执行覆盖同步 | + +### 请求示例 + +```bash +curl -X POST "http://localhost:8000/api/v2/printing-orders/sync-external-snapshot/" \ + -H "Authorization: Token " \ + -H "Content-Type: application/json" \ + -d '{ + "external_order_id": "KD20432358", + "allow_reset_stateflow": false + }' +``` + +## 响应 + +### 成功响应 (200 OK) + +```json +{ + "detail": "外部订单快照同步成功", + "audit_id": 12, + "external_order_id": "KD20432358", + "printing_order_id": 55, + "orders_created": 0, + "orders_updated": 1, + "jobs_created": 1, + "jobs_updated": 2, + "jobs_deleted": 1, + "reset_stateflow_job_count": 0 +} +``` + +### 成功响应字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `detail` | string | 结果说明 | +| `audit_id` | int | 同步审计记录ID | +| `external_order_id` | string | 本次同步的外部订单号 | +| `printing_order_id` | int | 本地印染订单ID | +| `orders_created` | int | 是否创建了新订单,`0` 或 `1` | +| `orders_updated` | int | 是否更新了已有订单,`0` 或 `1` | +| `jobs_created` | int | 新创建的 `PrintingJob` 数量 | +| `jobs_updated` | int | 被更新的 `PrintingJob` 数量 | +| `jobs_deleted` | int | 被删除的旧 `PrintingJob` 数量 | +| `reset_stateflow_job_count` | int | 本次先撤销工序的 `PrintingJob` 数量 | + +## 错误响应 + +### 1. 参数错误 (400 Bad Request) + +#### `external_order_id` 为空 + +```json +{ + "external_order_id": ["external_order_id 不能为空"] +} +``` + +### 2. 未认证 (401 Unauthorized) + +```json +{ + "detail": "Authentication credentials were not provided." +} +``` + +### 3. 无权限 (403 Forbidden) + +当用户不是印染工厂用户时: + +```json +{ + "detail": "您没有访问印染订单的权限" +} +``` + +### 4. 外部订单不存在 (404 Not Found) + +```json +{ + "detail": "未找到该 external_order_id 对应的订单", + "audit_id": 13, + "external_order_id": "KD-NOT-FOUND" +} +``` + +说明: + +- 即使外部未命中,也会创建失败审计记录 +- 可通过 `audit_id` 关联后端审计信息 + +### 5. 目标订单下已有销售品,禁止覆盖 (409 Conflict) + +```json +{ + "detail": "目标订单存在已关联销售品的 printing_job,禁止覆盖同步。 printing_job_ids=[101], sales_item_ids=[201]", + "audit_id": 14, + "external_order_id": "KD20432358" +} +``` + +说明: + +- 这是最优先的阻断条件 +- 一旦命中,不会继续请求外部快照接口 + +### 6. 目标订单下已有工序记录,默认禁止覆盖 (409 Conflict) + +```json +{ + "detail": "目标订单存在已执行工序的 printing_job,默认不允许覆盖同步。 如确认要覆盖,请传 allow_reset_stateflow=true。 printing_job_ids=[101]", + "audit_id": 15, + "external_order_id": "KD20432358" +} +``` + +说明: + +- 当前端收到这类错误时,可以提示用户确认是否改用 `allow_reset_stateflow=true` 再次发起请求 + +### 7. 旧明细存在业务引用,无法删除 (409 Conflict) + +在外部快照同步阶段,如果发现本地“待删除旧明细”仍被其他业务引用,也会失败: + +```json +{ + "detail": "以下待删除 printing_job 已关联销售单明细,禁止覆盖同步: [102]", + "audit_id": 16, + "external_order_id": "KD20432358" +} +``` + +## 审计说明 + +每次请求都会生成一条审计记录,无论成功还是失败。 + +当前审计记录包含: + +- `external_order_id` +- 操作者 `request.user` +- 操作员工 `request.user.employee` +- 同步前本地订单与明细快照(JSON) +- 本次是否允许撤销工序 +- 是否成功 +- 失败原因 + +前端当前只会收到: + +- `audit_id` + +如果后续需要展示详细审计记录,需要后端再补充查询接口。 + +## 前端使用建议 + +### 普通重同步 + +默认先使用: + +```json +{ + "external_order_id": "KD20432358" +} +``` + +### 当后端提示存在工序记录时 + +可以提示用户: + +- 覆盖同步会先撤销当前流程进度 +- 是否继续 + +用户确认后,再次发送: + +```json +{ + "external_order_id": "KD20432358", + "allow_reset_stateflow": true +} +``` + +### 不建议自动重试的情况 + +遇到以下错误时,前端不应自动重试: + +- 已关联销售品 +- 已关联销售单明细 +- 外部订单不存在 + +这些都应提示人工处理。 + +## 前端调用示例 + +```javascript +async function syncExternalSnapshot(externalOrderId, allowResetStateflow = false) { + const response = await fetch('/api/v2/printing-orders/sync-external-snapshot/', { + method: 'POST', + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + external_order_id: externalOrderId, + allow_reset_stateflow: allowResetStateflow, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + const error = new Error(data.detail || '同步失败'); + error.status = response.status; + error.auditId = data.audit_id; + error.externalOrderId = data.external_order_id; + throw error; + } + + return data; +} + +// 示例:先按默认模式重同步 +try { + const result = await syncExternalSnapshot('KD20432358'); + console.log('同步成功', result); +} catch (error) { + // 如果提示需要 allow_reset_stateflow=true,再由用户二次确认 + console.error(error.message, error.auditId); +} +``` + +## 相关文件 + +- 路由: [api_v2/urls.py](/home/f/coding/flower/api_v2/urls.py) +- 视图: [api_v2/views/printing.py](/home/f/coding/flower/api_v2/views/printing.py) +- 核心同步逻辑: [api_v1/tasks.py](/home/f/coding/flower/api_v1/tasks.py) +- 审计模型: [printing/models.py](/home/f/coding/flower/printing/models.py) diff --git a/docs/external_printing_order_snapshot_api_requirement_2026-04-16.md b/docs/external_printing_order_snapshot_api_requirement_2026-04-16.md new file mode 100644 index 0000000..5d9b16c --- /dev/null +++ b/docs/external_printing_order_snapshot_api_requirement_2026-04-16.md @@ -0,0 +1,365 @@ +# 外部印染订单“按 external_order_id 获取当前完整快照”接口需求 + +日期:2026-04-16 + +## 1. 背景 + +我方系统当前已接入外部印染 records 的增量同步能力。 + +现有同步模式为: + +- 外部系统通过增量接口返回 records +- 我方按 `BianHaoID` 分组,同步为 1 条 `PrintingOrder` +- 分组内每条 record 同步为 1 条 `PrintingJob` + +也就是说,在我方系统内,外部订单和本地数据关系为: + +- 1 个 `external_order_id`(即外部 `BianHaoID`) +- 对应 1 条本地 `PrintingOrder` +- 对应 N 条本地 `PrintingJob` + +当前业务中已经出现这样一种场景: + +- 某个外部订单已经完成首次同步 +- 之后外部系统中的订单头信息或明细信息又被人工修改 +- 我方人工发现本地数据与外部数据不一致 +- 需要针对某一个 `external_order_id` 手动触发“重新获取该订单当前最新数据” + +## 2. 当前问题 + +我方现有的 retry 能力,并不能解决“外部订单后来被修改”的问题。 + +当前 retry 的本质是: + +- 仅针对历史失败的外部 record +- 从我方失败表中取出当时保存的 `raw` 原始数据 +- 再次重放同一批旧数据 + +因此它适用于: + +- 网络失败 +- 产品图片拉取失败 +- 某次处理异常后重新跑一遍旧 payload + +但它不适用于: + +- 外部订单字段后来被改了 +- 外部订单新增了新的明细 +- 外部订单删掉了原有明细 +- 外部订单状态发生变化 + +原因是: + +- retry 并不会重新向外部系统按订单号取最新数据 +- retry 重放的仍然是旧快照,而不是外部当前状态 + +## 3. 需求目标 + +希望外部系统提供一个能力: + +> 按 `external_order_id` 精确查询,并返回该订单“当前完整快照”的全部 records。 + +这里的“当前完整快照”有明确含义: + +- 返回的是该订单当前时点下的完整明细集合 +- 不是历史增量 +- 不是游标区间结果 +- 不是“最近有变化的几条记录” +- 不依赖我方先知道哪些 record 发生过变化 + +这个能力将用于我方后续提供“按 external_order_id 手动重同步”的内部 API / 工具。 + +## 4. 为什么必须是“完整快照” + +我方不是单条明细落库,而是订单头 + 多条任务明细的结构。 + +如果外部系统只提供: + +- 某个订单最近变化的几条 record +- 或者只提供单条 record 查询 + +则我方无法可靠完成以下动作: + +- 更新订单头字段 +- 判断本地哪些明细需要新增 +- 判断本地哪些明细需要更新 +- 判断本地哪些旧明细已经在外部被删除 + +因此,对我方来说,最小可用能力不是“按单号查某几条变化记录”,而是: + +- 按单号返回该订单当前全部有效 records + +只有拿到完整快照,我方才能把这个订单重新对齐到外部当前状态。 + +## 5. 建议接口方案 + +推荐新增一个独立接口。 + +建议路径: + +```http +GET /api/v1/records/by-order?external_order_id=KD20432358 +``` + +推荐原因: + +- 语义清晰,和现有增量游标接口职责分离 +- 可以明确约束“不读 cursor、不推进 cursor” +- 便于后续扩展该接口的专属返回字段 + +如果外部系统暂时不方便新增路径,也可以接受兼容方案: + +```http +GET /api/v1/records?external_order_id=KD20432358&query_mode=snapshot +``` + +但前提必须满足: + +- 该模式下不使用游标逻辑 +- 不推进 cursor +- 返回该订单当前完整快照,而不是增量结果 + +## 6. 请求参数要求 + +### 必填参数 + +| 参数名 | 类型 | 说明 | +| --- | --- | --- | +| `external_order_id` | string | 外部订单编号,对应现有 records 中的 `BianHaoID` | + +### 匹配要求 + +- 必须按 `external_order_id` 精确匹配 +- 不应做模糊匹配 +- 不应返回多个订单的混合结果 + +### 关于唯一性 + +推荐外部系统保证: + +- `external_order_id` 在其业务域内能够唯一定位 1 张订单 + +如果外部系统暂时无法保证这一点,则需要在接口层明确处理: + +- 若命中 0 条订单,返回 not found +- 若命中多张订单,返回明确的重复错误,不能返回混合 records + +## 7. 返回数据要求 + +### 核心原则 + +返回的每条 record 字段结构,应尽量与现有增量接口 `GET /api/v1/records` 保持一致。 + +这样我方可以最大程度复用既有字段映射和同步逻辑。 + +### 推荐响应结构 + +```json +{ + "external_order_id": "KD20432358", + "status": "active", + "snapshot_at": "2026-04-16T10:12:34Z", + "total_count": 3, + "records": [ + { + "ID": 1000001, + "BianHaoID": "KD20432358", + "KhID": "KH00999", + "YanSe": "Tj1712#12号色-24码", + "ShuLiang": "10.00", + "JiJiaDW": "/段", + "ShuLiangZ": "2.68", + "BeiZhu": "手感一定要柔软", + "BeiZhuC": "10件", + "CaoZY": "钰涵", + "HpName": "120克本白四面弹单定", + "SeHao": "1.51", + "MeoA": "LWQ15", + "FidJ": "\\\\fw\\\\2026年-LWQ15\\\\2026\\\\H鸿烨\\\\Tj1712#", + "KdRiQi": "2026-01-26T20:00:52Z", + "area": "周边", + "customer": { + "KhID": "KH00999", + "KhName": "鸿烨服饰" + } + } + ] +} +``` + +### 推荐返回字段说明 + +| 字段名 | 类型 | 是否必需 | 说明 | +| --- | --- | --- | --- | +| `external_order_id` | string | 是 | 当前查询的订单号 | +| `status` | string | 建议 | 订单当前状态,建议值见下文 | +| `snapshot_at` | string(datetime) | 建议 | 当前快照生成时间 | +| `total_count` | integer | 是 | 当前快照下 records 数量 | +| `records` | array | 是 | 当前订单下的全部有效明细 | + +### `status` 建议枚举 + +建议至少支持以下值: + +- `active`:订单有效,`records` 为当前有效明细 +- `cancelled`:订单已取消 +- `deleted`:订单已删除或已逻辑删除 +- `not_found`:未找到该订单 + +说明: + +- 如果外部系统更倾向于用 HTTP 状态码表达,也可以配合状态码返回 +- 但建议仍返回明确业务语义,便于我方人工工具直接展示 + +## 8. 行为约束 + +这个接口需要满足以下行为约束。 + +### 8.1 不参与游标逻辑 + +必须保证: + +- 不读取当前增量 cursor 作为查询依据 +- 不推进 cursor +- 不影响现有增量同步任务的执行结果 + +这是最重要的约束之一。 + +### 8.2 返回整单当前全量明细 + +必须保证: + +- `records` 是这个订单当前全部有效明细 +- 不能只返回“最近变更的明细” +- 不能截断 +- 不能分页后只给第一页 + +如果确实存在分页压力,也请至少支持: + +- 单个 `external_order_id` 查询时直接返回该订单完整数据 + +因为我方对单号重同步的前提就是一次拿到完整快照。 + +### 8.3 快照一致性 + +必须尽量保证: + +- 同一次响应中的 `records` 来自同一时点 +- 不出现一半旧数据、一半新数据的混合快照 + +如果外部系统底层实现上存在事务或视图快照能力,建议使用该能力。 + +### 8.4 稳定排序 + +建议返回顺序固定,例如: + +- 按 `ID` 升序 + +这样有利于: + +- 人工核对 +- 接口调试 +- 我方日志审计 +- 幂等比较 + +## 9. 错误返回建议 + +### 9.1 未找到订单 + +建议: + +- HTTP `404 Not Found` + +```json +{ + "external_order_id": "KD20432358", + "status": "not_found", + "message": "未找到该 external_order_id 对应的订单" +} +``` + +### 9.2 命中多张订单 + +建议: + +- HTTP `409 Conflict` 或 `400 Bad Request` + +```json +{ + "external_order_id": "KD20432358", + "message": "该 external_order_id 命中多张订单,无法返回单一快照" +} +``` + +### 9.3 服务内部错误 + +建议: + +- HTTP `500 Internal Server Error` + +```json +{ + "message": "查询订单快照失败,请稍后重试" +} +``` + +## 10. 我方使用方式 + +在外部系统提供该接口后,我方计划这样使用: + +1. 由人工输入或选择 `external_order_id` +2. 我方服务调用该接口获取该订单当前完整快照 +3. 我方按现有映射规则重新同步该订单头信息和明细信息 +4. 若未来补充“整单对账清理”逻辑,还会基于该快照识别哪些本地旧明细已在外部不存在 + +因此,这个接口是我方后续“单号级人工纠偏同步”能力的前置条件。 + +## 11. 最小可用验收标准 + +外部接口完成后,至少需要满足以下验收标准: + +1. 能按 `external_order_id` 精确查询 +2. 返回该订单当前全部有效 records +3. 返回字段结构与现有增量接口的单条 record 基本一致 +4. 查询过程不推进 cursor,也不影响增量同步 +5. 未命中或歧义命中时,返回明确错误,而不是空数组混过 + +## 12. 推荐实现优先级 + +建议外部系统按如下优先级实现: + +### P0 + +- 支持按 `external_order_id` 查询 +- 返回整单完整快照 +- 不影响 cursor + +### P1 + +- 增加 `snapshot_at` +- 增加 `status` +- 明确未找到 / 命中多张订单的错误语义 + +### P2 + +- 若未来需要,也可以补充订单级更新时间字段,例如 `order_updated_at` +- 便于我方后续做缓存或重复请求优化 + +## 13. 结论 + +我方当前需要的不是“按单号查若干变更明细”,而是: + +> 按 `external_order_id` 返回该订单当前完整快照。 + +这是因为我方本地结构是: + +- 1 张订单头 +- 对应 N 条任务明细 + +只有拿到整单当前全量快照,我方才能支持: + +- 人工按单号重同步 +- 修复首次同步后外部又发生变更的场景 +- 后续扩展为整单级对账更新 + +因此,建议外部系统尽快提供上述查询接口能力。 \ No newline at end of file diff --git a/printing/migrations/0038_printingorderexternalsnapshotsyncaudit.py b/printing/migrations/0038_printingorderexternalsnapshotsyncaudit.py new file mode 100644 index 0000000..a900b06 --- /dev/null +++ b/printing/migrations/0038_printingorderexternalsnapshotsyncaudit.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0026_transportvehicle_and_capacity'), + ('printing', '0037_printingjob_is_production_completed'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PrintingOrderExternalSnapshotSyncAudit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('external_order_id', models.CharField(db_index=True, max_length=100, verbose_name='外部订单编号')), + ('before_snapshot', models.JSONField(blank=True, default=dict, verbose_name='同步前快照')), + ('allow_reset_stateflow', models.BooleanField(default=False, verbose_name='允许撤销工序')), + ('is_success', models.BooleanField(default=False, verbose_name='是否成功')), + ('failure_reason', models.TextField(blank=True, default='', verbose_name='失败原因')), + ('operator_employee', models.ForeignKey(blank=True, null=True, on_delete=models.SET_NULL, related_name='printing_order_external_snapshot_sync_audits', to='basic_info.employee', verbose_name='操作员工')), + ('operator_user', models.ForeignKey(blank=True, null=True, on_delete=models.SET_NULL, related_name='printing_order_external_snapshot_sync_audits', to=settings.AUTH_USER_MODEL, verbose_name='操作者')), + ('printing_order', models.ForeignKey(blank=True, null=True, on_delete=models.SET_NULL, related_name='external_snapshot_sync_audits', to='printing.printingorder', verbose_name='印染订单')), + ], + options={ + 'verbose_name': '外部订单快照同步审计记录', + 'verbose_name_plural': '外部订单快照同步审计记录', + 'ordering': ['-created_at', '-id'], + }, + ), + ] \ No newline at end of file diff --git a/printing/models.py b/printing/models.py index 2b7b734..43ef1c1 100644 --- a/printing/models.py +++ b/printing/models.py @@ -657,3 +657,71 @@ class PrintingJobBatchAdvanceRecord(ModelBase): class Meta: verbose_name = '印染任务批量操作记录' verbose_name_plural = '印染任务批量操作记录' + + +class PrintingOrderExternalSnapshotSyncAudit(ModelBase): + """ + 外部订单快照同步审计记录。 + + 记录一次“按 external_order_id 触发外部快照覆盖同步”的执行结果: + - before_snapshot: 同步前本地 PrintingOrder / PrintingJob 快照 + - operator_user / operator_employee: 触发人 + - is_success / failure_reason: 成功或失败及失败原因 + - allow_reset_stateflow: 本次是否允许撤销已有工序 + """ + + printing_order = models.ForeignKey( + PrintingOrder, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='external_snapshot_sync_audits', + verbose_name='印染订单', + ) + external_order_id = models.CharField( + max_length=100, + db_index=True, + verbose_name='外部订单编号', + ) + operator_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='printing_order_external_snapshot_sync_audits', + verbose_name='操作者', + ) + operator_employee = models.ForeignKey( + basic_models.Employee, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='printing_order_external_snapshot_sync_audits', + verbose_name='操作员工', + ) + before_snapshot = models.JSONField( + default=dict, + blank=True, + verbose_name='同步前快照', + ) + allow_reset_stateflow = models.BooleanField( + default=False, + verbose_name='允许撤销工序', + ) + is_success = models.BooleanField( + default=False, + verbose_name='是否成功', + ) + failure_reason = models.TextField( + blank=True, + default='', + verbose_name='失败原因', + ) + + class Meta: + verbose_name = '外部订单快照同步审计记录' + verbose_name_plural = '外部订单快照同步审计记录' + ordering = ['-created_at', '-id'] + + def __str__(self) -> str: + return f'{self.external_order_id} #{self.id}'