forked from erp-dev/erp
feat: external_order refetch api
This commit is contained in:
445
api_v1/tasks.py
445
api_v1/tasks.py
@@ -16,6 +16,7 @@ import requests
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -39,6 +40,23 @@ logger = logging.getLogger(__name__)
|
|||||||
User = get_user_model()
|
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:
|
def _ensure_backup_dir(output_dir: str | None) -> Path:
|
||||||
base_dir = Path(settings.BASE_DIR)
|
base_dir = Path(settings.BASE_DIR)
|
||||||
backup_dir = Path(output_dir) if output_dir else (base_dir / 'data-bak')
|
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
|
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 "<empty>"}',
|
||||||
|
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 "<empty>"}',
|
||||||
|
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]]:
|
def _group_external_records(records: list[dict]) -> dict[str, list[dict]]:
|
||||||
grouped: dict[str, list[dict]] = defaultdict(list)
|
grouped: dict[str, list[dict]] = defaultdict(list)
|
||||||
for record in records:
|
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(
|
def retry_external_printing_sync_failures_impl(
|
||||||
*,
|
*,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
|
|||||||
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,
|
PrintingJobBatchAdvancePreviewView,
|
||||||
PrintingJobBatchAdvanceSubmitView,
|
PrintingJobBatchAdvanceSubmitView,
|
||||||
PrintingJobBatchAddParametersView,
|
PrintingJobBatchAddParametersView,
|
||||||
|
PrintingOrderExternalSnapshotSyncView,
|
||||||
PlateOrderByProcessNodeView,
|
PlateOrderByProcessNodeView,
|
||||||
PlateOrderByProcessView,
|
PlateOrderByProcessView,
|
||||||
PlateOrderByStateStatusView,
|
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/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-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-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('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-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'),
|
path('plate-orders/by-process/', PlateOrderByProcessView.as_view(), name='api_v2_plate_order_by_process'),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .printing import (
|
|||||||
PrintingJobBatchAdvancePreviewView,
|
PrintingJobBatchAdvancePreviewView,
|
||||||
PrintingJobBatchAdvanceSubmitView,
|
PrintingJobBatchAdvanceSubmitView,
|
||||||
PrintingJobBatchAddParametersView,
|
PrintingJobBatchAddParametersView,
|
||||||
|
PrintingOrderExternalSnapshotSyncView,
|
||||||
PlateOrderByProcessNodeView,
|
PlateOrderByProcessNodeView,
|
||||||
PlateOrderByProcessView,
|
PlateOrderByProcessView,
|
||||||
PlateOrderByStateStatusView,
|
PlateOrderByStateStatusView,
|
||||||
@@ -40,6 +41,7 @@ __all__ = [
|
|||||||
'PrintingJobBatchAdvancePreviewView',
|
'PrintingJobBatchAdvancePreviewView',
|
||||||
'PrintingJobBatchAdvanceSubmitView',
|
'PrintingJobBatchAdvanceSubmitView',
|
||||||
'PrintingJobBatchAddParametersView',
|
'PrintingJobBatchAddParametersView',
|
||||||
|
'PrintingOrderExternalSnapshotSyncView',
|
||||||
'PlateOrderByProcessNodeView',
|
'PlateOrderByProcessNodeView',
|
||||||
'PlateOrderByProcessView',
|
'PlateOrderByProcessView',
|
||||||
'PlateOrderByStateStatusView',
|
'PlateOrderByStateStatusView',
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
from printing import models as printing_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_man.serializers import ProductSerializer
|
||||||
from api_v1.views.printing.serializers import (
|
from api_v1.views.printing.serializers import (
|
||||||
PlateOrderListSerializer as PlateOrderListV1Serializer,
|
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):
|
class PrintingJobBatchAddParametersRequestSerializer(serializers.Serializer):
|
||||||
"""批量补充参数请求序列化器"""
|
"""批量补充参数请求序列化器"""
|
||||||
printing_job_ids = serializers.ListField(
|
printing_job_ids = serializers.ListField(
|
||||||
|
|||||||
320
docs/api_v2_printing_order_sync_external_snapshot.md
Normal file
320
docs/api_v2_printing_order_sync_external_snapshot.md
Normal file
@@ -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 <your-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)
|
||||||
@@ -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 条任务明细
|
||||||
|
|
||||||
|
只有拿到整单当前全量快照,我方才能支持:
|
||||||
|
|
||||||
|
- 人工按单号重同步
|
||||||
|
- 修复首次同步后外部又发生变更的场景
|
||||||
|
- 后续扩展为整单级对账更新
|
||||||
|
|
||||||
|
因此,建议外部系统尽快提供上述查询接口能力。
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -657,3 +657,71 @@ class PrintingJobBatchAdvanceRecord(ModelBase):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '印染任务批量操作记录'
|
verbose_name = '印染任务批量操作记录'
|
||||||
verbose_name_plural = '印染任务批量操作记录'
|
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}'
|
||||||
|
|||||||
Reference in New Issue
Block a user