forked from erp-dev/erp
2137 lines
75 KiB
Python
2137 lines
75 KiB
Python
import asyncio
|
||
import base64
|
||
import json
|
||
import logging
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from collections import defaultdict
|
||
from datetime import date, datetime
|
||
from decimal import Decimal, InvalidOperation
|
||
from pathlib import Path
|
||
|
||
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
|
||
|
||
from basic_info import models as basic_models
|
||
from api_v1 import models as api_models
|
||
from api_v1.views.printing.services import PrintingJobService, PrintingOrderService
|
||
from flower.utils import (
|
||
fetch_products_from_mingdaoyun,
|
||
fetch_customers_from_mingdaoyun,
|
||
)
|
||
from api_v1.mdy_plate_order_sync import sync_mdy_plate_orders_to_staging
|
||
from api_v1.mdy_plate_order_staging_tiia_upload import (
|
||
build_default_tiia_rate_limiter,
|
||
upload_mdy_plate_order_staging_plate_images_to_tencent_tiia,
|
||
)
|
||
from api_v1.external_datetime import parse_external_china_datetime
|
||
from api_v1.external_product_image_backfill import run_external_product_image_backfill
|
||
from printing import models as printing_models
|
||
|
||
|
||
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')
|
||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||
return backup_dir
|
||
|
||
|
||
def _build_backup_path(backup_dir: Path, filename_prefix: str) -> Path:
|
||
timestamp = timezone.now().strftime('%Y%m%d-%H%M%S')
|
||
return backup_dir / f'{filename_prefix}-{timestamp}.sql'
|
||
|
||
|
||
def _run_pg_dump(backup_path: Path):
|
||
db_settings = settings.DATABASES['default']
|
||
pg_dump = shutil.which('pg_dump')
|
||
if not pg_dump:
|
||
raise RuntimeError('pg_dump 不存在,请确认 PostgreSQL 客户端工具已安装')
|
||
|
||
host = db_settings.get('HOST') or 'localhost'
|
||
port = db_settings.get('PORT') or '5432'
|
||
user = db_settings.get('USER') or ''
|
||
name = db_settings['NAME']
|
||
password = db_settings.get('PASSWORD') or ''
|
||
|
||
cmd = [
|
||
pg_dump,
|
||
'-h',
|
||
host,
|
||
'-p',
|
||
str(port),
|
||
'-U',
|
||
user,
|
||
'-F',
|
||
'p',
|
||
'-d',
|
||
name,
|
||
]
|
||
|
||
env = os.environ.copy()
|
||
if password:
|
||
env['PGPASSWORD'] = password
|
||
|
||
with backup_path.open('wb') as stream:
|
||
subprocess.run(cmd, check=True, stdout=stream, env=env)
|
||
|
||
|
||
def _dump_database_to_sql(backup_path: Path):
|
||
engine = settings.DATABASES['default']['ENGINE']
|
||
if 'postgresql' not in engine:
|
||
raise NotImplementedError('当前项目只支持 PostgreSQL 数据库备份,请检查 DATABASES 配置')
|
||
_run_pg_dump(backup_path)
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def backup_database(self, output_dir: str | None = None, filename_prefix: str = 'db-backup'):
|
||
"""
|
||
备份当前数据库为 .sql 文件(仅数据,不包含表结构 DDL),存放在项目根目录 data-bak 下。
|
||
|
||
参数:
|
||
output_dir: 可选,指定备份目录(默认 BASE_DIR/data-bak)
|
||
filename_prefix: 备份文件名前缀
|
||
"""
|
||
backup_dir = _ensure_backup_dir(output_dir)
|
||
backup_path = _build_backup_path(backup_dir, filename_prefix)
|
||
_dump_database_to_sql(backup_path)
|
||
|
||
payload = {
|
||
'task_id': self.request.id,
|
||
'backup_path': str(backup_path),
|
||
'created_at': timezone.now().isoformat(),
|
||
}
|
||
logger.info('数据库备份完成: %s', payload)
|
||
return payload
|
||
|
||
|
||
def _get_mdy_merchant(merchant_id: int | None = None):
|
||
merchant_id = merchant_id or getattr(settings, 'MDY_MERCHANT_ID', None)
|
||
qs = basic_models.Merchant.objects.all()
|
||
if merchant_id:
|
||
qs = qs.filter(id=merchant_id)
|
||
merchant = qs.order_by('id').first()
|
||
if not merchant:
|
||
raise RuntimeError('未找到用于明道云同步的商户,请先创建商户或配置 MDY_MERCHANT_ID')
|
||
return merchant
|
||
|
||
|
||
def _get_mdy_category(merchant):
|
||
category_id = getattr(settings, 'MDY_PRODUCT_CATEGORY_ID', None)
|
||
qs = basic_models.ProductCategory.objects.filter(merchant=merchant)
|
||
if category_id:
|
||
qs = qs.filter(id=category_id)
|
||
category = qs.order_by('id').first()
|
||
if not category:
|
||
raise RuntimeError('未找到用于明道云同步的产品类别,请先创建类别或配置 MDY_PRODUCT_CATEGORY_ID')
|
||
return category
|
||
|
||
|
||
def _parse_mdy_datetime(value: str | None):
|
||
if not value:
|
||
return None
|
||
try:
|
||
dt = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
|
||
except ValueError:
|
||
return None
|
||
if timezone.is_naive(dt):
|
||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||
return dt
|
||
|
||
|
||
def _map_unit(unit_label: str | None) -> int:
|
||
if not unit_label:
|
||
return basic_models.ProductUnitEnum.METER
|
||
mapping = {
|
||
'米': basic_models.ProductUnitEnum.METER,
|
||
'码': basic_models.ProductUnitEnum.YARD,
|
||
'公斤': basic_models.ProductUnitEnum.KG,
|
||
'段': basic_models.ProductUnitEnum.SEGMENT,
|
||
}
|
||
return mapping.get(unit_label, basic_models.ProductUnitEnum.METER)
|
||
|
||
|
||
def _ensure_decimal(value: str | None):
|
||
if not value:
|
||
return None
|
||
try:
|
||
return Decimal(str(value))
|
||
except (InvalidOperation, TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _ensure_int(value):
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError, InvalidOperation):
|
||
# 有些值可能是 Decimal 或字符串数字
|
||
try:
|
||
return int(Decimal(str(value)))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _parse_external_datetime(value: str | None):
|
||
if not value:
|
||
return None
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
dt = datetime.fromisoformat(text.replace('Z', '+00:00'))
|
||
except ValueError:
|
||
return None
|
||
if timezone.is_naive(dt):
|
||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||
return dt
|
||
|
||
|
||
def _extract_positive_int(value, *, field_name: str, allow_blank: bool = True):
|
||
if value is None or value == '':
|
||
return None if allow_blank else 0
|
||
try:
|
||
number = Decimal(str(value))
|
||
except (InvalidOperation, TypeError, ValueError) as exc:
|
||
raise RuntimeError(f'外部字段 {field_name} 无法解析为整数: {value!r}') from exc
|
||
if number != number.to_integral_value():
|
||
raise RuntimeError(f'外部字段 {field_name} 不是整数: {value!r}')
|
||
parsed = int(number)
|
||
if parsed < 0:
|
||
raise RuntimeError(f'外部字段 {field_name} 不能为负数: {value!r}')
|
||
return parsed
|
||
|
||
|
||
def _extract_pieces(value):
|
||
if value is None or value == '':
|
||
return None
|
||
match = re.search(r'(\d+)', str(value))
|
||
if not match:
|
||
return None
|
||
return int(match.group(1))
|
||
|
||
|
||
def _normalize_unit(value):
|
||
text = str(value or '').strip()
|
||
return text.lstrip('/').strip() or text
|
||
|
||
|
||
def _split_fabric_source_and_craft(value: str | None):
|
||
text = str(value or '').strip()
|
||
if not text:
|
||
return '', ''
|
||
parts = re.split(r'\s+', text)
|
||
fabric_source = parts[0] if parts else ''
|
||
craft = ' '.join(parts[1:]).strip() if len(parts) > 1 else ''
|
||
return fabric_source, craft
|
||
|
||
|
||
def _build_external_url(path: str) -> str:
|
||
base_url = str(getattr(settings, 'PRINTING_EXTERNAL_RECORDS_BASE_URL', '') or '').rstrip('/')
|
||
if not base_url:
|
||
raise RuntimeError('未配置 PRINTING_EXTERNAL_RECORDS_BASE_URL')
|
||
return f'{base_url}{path}'
|
||
|
||
|
||
def _get_external_headers() -> dict[str, str]:
|
||
secret = str(getattr(settings, 'PRINTING_EXTERNAL_RECORDS_AUTHORIZATION', '') or '').strip()
|
||
if not secret:
|
||
raise RuntimeError('未配置 PRINTING_EXTERNAL_RECORDS_AUTHORIZATION')
|
||
return {'Authorization': secret}
|
||
|
||
|
||
def _get_printing_sync_user():
|
||
user_id = int(getattr(settings, 'PRINTING_EXTERNAL_SYNC_USER_ID', 1) or 1)
|
||
user = User.objects.filter(id=user_id).select_related('employee__merchant').first()
|
||
if not user:
|
||
raise RuntimeError(f'未找到固定同步用户: {user_id}')
|
||
employee = getattr(user, 'employee', None)
|
||
merchant = getattr(employee, 'merchant', None)
|
||
if not merchant:
|
||
raise RuntimeError(f'固定同步用户 {user_id} 未绑定 employee.merchant,无法确定同步商户')
|
||
return user
|
||
|
||
|
||
def _get_printing_sync_product_category(merchant):
|
||
category_id = getattr(settings, 'PRINTING_EXTERNAL_SYNC_PRODUCT_CATEGORY_ID', None)
|
||
qs = basic_models.ProductCategory.objects.filter(merchant=merchant).order_by('id')
|
||
if category_id:
|
||
qs = qs.filter(id=category_id)
|
||
category = qs.first()
|
||
if not category:
|
||
raise RuntimeError(
|
||
f'未找到用于外部印染同步的产品类别,请先创建类别或配置 '
|
||
f'PRINTING_EXTERNAL_SYNC_PRODUCT_CATEGORY_ID(merchant={merchant.id})'
|
||
)
|
||
return category
|
||
|
||
|
||
def _fetch_external_printing_records(*, limit: int = 100) -> dict:
|
||
url = _build_external_url('/api/v1/records')
|
||
response = requests.get(
|
||
url,
|
||
headers=_get_external_headers(),
|
||
params={'limit': max(1, int(limit)), 'update_cursor': 'false'},
|
||
timeout=30,
|
||
)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError('外部 records 接口返回格式非法:非 JSON object')
|
||
if not isinstance(payload.get('records', []), list):
|
||
raise RuntimeError('外部 records 接口返回格式非法:records 不是数组')
|
||
return payload
|
||
|
||
|
||
def _advance_external_printing_cursor(*, cursor_value: int) -> dict:
|
||
url = _build_external_url('/api/v1/cursor/set')
|
||
response = requests.post(
|
||
url,
|
||
headers=_get_external_headers(),
|
||
json={'value': int(cursor_value)},
|
||
timeout=30,
|
||
)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError('外部 cursor/set 接口返回格式非法:非 JSON object')
|
||
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,
|
||
'sub_id': job.sub_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]]:
|
||
grouped: dict[str, list[dict]] = defaultdict(list)
|
||
for record in records:
|
||
external_order_id = str(record.get('BianHaoID') or '').strip()
|
||
if not external_order_id:
|
||
raise RuntimeError(f'外部 record 缺少 BianHaoID: {record!r}')
|
||
grouped[external_order_id].append(record)
|
||
return grouped
|
||
|
||
|
||
def _resolve_external_customer(*, merchant, record: dict):
|
||
customer_payload = record.get('customer') or {}
|
||
customer_name = str(customer_payload.get('KhName') or '').strip()
|
||
external_customer_id = str(record.get('KhID') or customer_payload.get('KhID') or '').strip()
|
||
if not customer_name:
|
||
raise RuntimeError(f'外部 record 缺少 customer.KhName: {record!r}')
|
||
|
||
customer = (
|
||
basic_models.Customer.objects.filter(merchant=merchant, name=customer_name)
|
||
.order_by('id')
|
||
.first()
|
||
)
|
||
if not customer:
|
||
defaults = {
|
||
'merchant': merchant,
|
||
'name': customer_name,
|
||
}
|
||
sync_user = record.get('_sync_user')
|
||
employee = getattr(sync_user, 'employee', None)
|
||
if employee and employee.merchant_id == merchant.id:
|
||
defaults['created_by'] = employee
|
||
customer = basic_models.Customer.objects.create(**defaults)
|
||
logger.info(
|
||
'外部印染同步自动创建客户: merchant_id=%s customer_name=%s external_customer_id=%s customer_id=%s',
|
||
merchant.id,
|
||
customer_name,
|
||
external_customer_id,
|
||
customer.id,
|
||
)
|
||
return customer, customer_name, external_customer_id
|
||
|
||
|
||
def _resolve_external_employee_user(*, merchant, external_employee_name: str, sync_user):
|
||
normalized = str(external_employee_name or '').strip()
|
||
if not normalized:
|
||
return sync_user, ''
|
||
|
||
employee = (
|
||
basic_models.Employee.objects.filter(merchant=merchant, name=normalized)
|
||
.select_related('sys_user')
|
||
.order_by('id')
|
||
.first()
|
||
)
|
||
if employee and employee.sys_user_id:
|
||
return employee.sys_user, ''
|
||
return sync_user, normalized
|
||
|
||
|
||
def _encode_external_image_name_b64(name: str) -> str:
|
||
raw = str(name or '').encode('utf-8')
|
||
return base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
|
||
|
||
|
||
def _decode_external_image_data(data: str) -> bytes:
|
||
text = str(data or '').strip()
|
||
if not text:
|
||
raise RuntimeError('外部图片接口返回空 data')
|
||
padding = '=' * (-len(text) % 4)
|
||
try:
|
||
return base64.b64decode(text + padding)
|
||
except Exception as exc:
|
||
raise RuntimeError('外部图片接口返回的 data 不是合法 base64') from exc
|
||
|
||
|
||
def _fetch_external_product_image(external_product_name: str) -> dict:
|
||
url = _build_external_url('/api/v1/image')
|
||
response = requests.get(
|
||
url,
|
||
headers=_get_external_headers(),
|
||
params={'name_b64': _encode_external_image_name_b64(external_product_name)},
|
||
timeout=30,
|
||
)
|
||
if response.status_code == 404:
|
||
raise RuntimeError(f'外部图片不存在: {external_product_name}')
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError('外部图片接口返回格式非法:非 JSON object')
|
||
data = _decode_external_image_data(payload.get('data', ''))
|
||
content_type = str(payload.get('content_type') or '').strip() or 'application/octet-stream'
|
||
image_name = str(payload.get('name') or '').strip()
|
||
return {
|
||
'bytes': data,
|
||
'content_type': content_type,
|
||
'name': image_name or external_product_name,
|
||
}
|
||
|
||
|
||
def _build_product_image_filename(product_name: str, image_name: str, content_type: str) -> str:
|
||
suffix = Path(str(image_name or '').strip()).suffix
|
||
if not suffix:
|
||
suffix = mimetypes.guess_extension(content_type or '') or '.jpg'
|
||
safe_name = re.sub(r'[^0-9A-Za-z._-]+', '_', str(product_name or '').strip()) or 'external_product'
|
||
return f'{safe_name}{suffix}'
|
||
|
||
|
||
def _upload_product_image(product, image_payload: dict):
|
||
filename = _build_product_image_filename(
|
||
product_name=product.name,
|
||
image_name=image_payload.get('name', ''),
|
||
content_type=image_payload.get('content_type', ''),
|
||
)
|
||
content = ContentFile(image_payload['bytes'])
|
||
product.image.save(filename, content, save=False)
|
||
product.save(update_fields=['image', 'updated_at'])
|
||
|
||
|
||
def _external_product_allows_missing_image(external_product_name: str) -> bool:
|
||
return '白布' in str(external_product_name or '').strip()
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def backfill_external_product_images(
|
||
self,
|
||
merchant_id: int | None = None,
|
||
product_id: int | None = None,
|
||
limit: int = 500,
|
||
dry_run: bool = False,
|
||
):
|
||
"""
|
||
为来自外部印染订单的产品补图。
|
||
|
||
仅处理:
|
||
- printing_order.external_order_id 非空
|
||
- product.image 为空
|
||
"""
|
||
payload = run_external_product_image_backfill(
|
||
fetch_image=_fetch_external_product_image,
|
||
upload_image=_upload_product_image,
|
||
merchant_id=merchant_id,
|
||
product_id=product_id,
|
||
limit=max(1, int(limit or 500)),
|
||
dry_run=bool(dry_run),
|
||
on_success=lambda message: logger.info(message),
|
||
on_error=lambda message: logger.error(message),
|
||
on_info=lambda message: logger.info(message),
|
||
)
|
||
payload["task_id"] = self.request.id
|
||
logger.info("外部印染订单产品补图完成: %s", payload)
|
||
return payload
|
||
|
||
|
||
def _record_printing_external_sync_failure(*, run_date, record: dict, error: str):
|
||
external_record_id = _extract_positive_int(record.get('ID'), field_name='ID', allow_blank=False)
|
||
obj, _created = api_models.PrintingExternalSyncFailure.objects.get_or_create(
|
||
run_date=run_date,
|
||
external_record_id=external_record_id,
|
||
defaults={
|
||
'external_order_id': str(record.get('BianHaoID') or '').strip(),
|
||
'product_name': str(record.get('YanSe') or '').strip(),
|
||
'error': error or '',
|
||
'raw': record,
|
||
'attempts': 0,
|
||
'last_attempt_at': timezone.now(),
|
||
},
|
||
)
|
||
obj.external_order_id = str(record.get('BianHaoID') or '').strip()
|
||
obj.product_name = str(record.get('YanSe') or '').strip()
|
||
obj.error = error or ''
|
||
obj.raw = record
|
||
obj.attempts = int(obj.attempts or 0) + 1
|
||
obj.last_attempt_at = timezone.now()
|
||
obj.save(
|
||
update_fields=[
|
||
'external_order_id',
|
||
'product_name',
|
||
'error',
|
||
'raw',
|
||
'attempts',
|
||
'last_attempt_at',
|
||
]
|
||
)
|
||
|
||
|
||
def _ensure_external_product(*, merchant, external_product_name: str, category):
|
||
normalized = str(external_product_name or '').strip()
|
||
if not normalized:
|
||
raise RuntimeError('外部产品名为空,无法创建/绑定产品')
|
||
|
||
allows_missing_image = _external_product_allows_missing_image(normalized)
|
||
existing_product = (
|
||
basic_models.Product.objects.filter(merchant=merchant, name=normalized)
|
||
.order_by('id')
|
||
.first()
|
||
)
|
||
if existing_product:
|
||
if existing_product.image:
|
||
return existing_product, False
|
||
if allows_missing_image:
|
||
return existing_product, False
|
||
image_payload = _fetch_external_product_image(normalized)
|
||
_upload_product_image(existing_product, image_payload)
|
||
return existing_product, False
|
||
|
||
product = basic_models.Product.objects.create(
|
||
merchant=merchant,
|
||
category=category,
|
||
name=normalized,
|
||
)
|
||
try:
|
||
image_payload = _fetch_external_product_image(normalized)
|
||
_upload_product_image(product, image_payload)
|
||
except Exception as exc:
|
||
if allows_missing_image:
|
||
logger.warning(
|
||
'外部白布产品图片缺失,已创建无图 Product: product_id=%s product_name=%s error=%s',
|
||
product.id,
|
||
normalized,
|
||
exc,
|
||
)
|
||
return product, True
|
||
if product.image:
|
||
try:
|
||
product.image.delete(save=False)
|
||
except Exception:
|
||
logger.warning('回滚外部产品图片失败: product_id=%s', product.id, exc_info=True)
|
||
product.delete()
|
||
raise
|
||
return product, True
|
||
|
||
|
||
def _build_order_external_raw(group_records: list[dict]) -> dict:
|
||
first = group_records[0] if group_records else {}
|
||
return {
|
||
'group_size': len(group_records),
|
||
'record_ids': [record.get('ID') for record in group_records],
|
||
'first_record': first,
|
||
}
|
||
|
||
|
||
def _build_external_order_data(
|
||
*,
|
||
merchant,
|
||
external_order_id: str,
|
||
group_records: list[dict],
|
||
sync_user,
|
||
):
|
||
first = group_records[0]
|
||
first_with_context = dict(first)
|
||
first_with_context['_sync_user'] = sync_user
|
||
customer, external_customer_name, external_customer_id = _resolve_external_customer(
|
||
merchant=merchant,
|
||
record=first_with_context,
|
||
)
|
||
created_by_user, external_employee_name = _resolve_external_employee_user(
|
||
merchant=merchant,
|
||
external_employee_name=str(first.get('CaoZY') or '').strip(),
|
||
sync_user=sync_user,
|
||
)
|
||
fabric_source, craft = _split_fabric_source_and_craft(first.get('SHDZ'))
|
||
return {
|
||
'merchant': merchant,
|
||
'customer': customer,
|
||
'fabric': str(first.get('HpName') or '').strip(),
|
||
'width': str(first.get('SeHao') or '').strip(),
|
||
'area': str(first.get('area') or '').strip(),
|
||
'fabric_source': fabric_source or None,
|
||
'craft': craft or None,
|
||
'rolling_warn': str(first.get('BeiZhu') or '').strip() or None,
|
||
'curve': str(first.get('MeoA') or '').strip() or None,
|
||
'position': str(first.get('FidJ') or '').strip() or None,
|
||
'kd_riqi': parse_external_china_datetime(first.get('KdRiQi')),
|
||
'outgoing_date': parse_external_china_datetime(first.get('KdRiQi')),
|
||
'created_by': created_by_user,
|
||
'external_order_id': external_order_id,
|
||
'external_customer_id': external_customer_id or None,
|
||
'external_customer_name': external_customer_name or None,
|
||
'external_employee_name': external_employee_name or None,
|
||
'external_raw': _build_order_external_raw(group_records),
|
||
}
|
||
|
||
|
||
def _upsert_external_printing_order(*, merchant, external_order_id: str, group_records: list[dict], sync_user):
|
||
order_data = _build_external_order_data(
|
||
merchant=merchant,
|
||
external_order_id=external_order_id,
|
||
group_records=group_records,
|
||
sync_user=sync_user,
|
||
)
|
||
created_by_user = order_data['created_by']
|
||
existing_order = (
|
||
printing_models.PrintingOrder.objects.filter(
|
||
merchant=merchant,
|
||
external_order_id=external_order_id,
|
||
)
|
||
.order_by('id')
|
||
.first()
|
||
)
|
||
if existing_order:
|
||
success, message, order = PrintingOrderService.update_printing_order(
|
||
existing_order,
|
||
order_data,
|
||
created_by_user,
|
||
)
|
||
if not success:
|
||
raise RuntimeError(f'更新印染订单失败: {message}')
|
||
return order, False
|
||
order = PrintingOrderService.create_printing_order(order_data, created_by_user)
|
||
return order, True
|
||
|
||
|
||
def _upsert_external_printing_job(*, merchant, printing_order, record: dict, sync_user, category):
|
||
record_id = _extract_positive_int(record.get('ID'), field_name='ID', allow_blank=False)
|
||
external_product_name = str(record.get('YanSe') or '').strip()
|
||
created_by_user, _external_employee_name = _resolve_external_employee_user(
|
||
merchant=merchant,
|
||
external_employee_name=str(record.get('CaoZY') or '').strip(),
|
||
sync_user=sync_user,
|
||
)
|
||
product, _ = _ensure_external_product(
|
||
merchant=merchant,
|
||
external_product_name=external_product_name,
|
||
category=category,
|
||
)
|
||
job_data = {
|
||
'merchant': merchant,
|
||
'printing_order': printing_order,
|
||
'product': product,
|
||
'quantity': _extract_positive_int(record.get('ShuLiang'), field_name='ShuLiang', allow_blank=False),
|
||
'unit': _normalize_unit(record.get('JiJiaDW')),
|
||
'size': str(record.get('ShuLiangZ') or '').strip() or None,
|
||
'pieces': _extract_pieces(record.get('BeiZhuC')),
|
||
'description': None,
|
||
'original_id': record_id,
|
||
'sub_id': _extract_positive_int(record.get('SubID'), field_name='SubID'),
|
||
'created_by': created_by_user,
|
||
'external_product_name': None,
|
||
'external_raw': record,
|
||
}
|
||
existing_job = (
|
||
printing_models.PrintingJob.objects.filter(
|
||
printing_order=printing_order,
|
||
product=product,
|
||
)
|
||
.order_by('id')
|
||
.first()
|
||
)
|
||
if existing_job:
|
||
success, message, job = PrintingJobService.update_printing_job(
|
||
existing_job,
|
||
job_data,
|
||
created_by_user,
|
||
)
|
||
if not success:
|
||
raise RuntimeError(f'更新印染任务失败: {message}')
|
||
return job, False
|
||
job = PrintingJobService.create_printing_job(job_data, created_by_user)
|
||
return job, True
|
||
|
||
|
||
def _sync_external_printing_records_batch(
|
||
*,
|
||
records: list[dict],
|
||
merchant,
|
||
sync_user,
|
||
category,
|
||
record_failure,
|
||
) -> dict:
|
||
orders_created = 0
|
||
orders_updated = 0
|
||
jobs_created = 0
|
||
jobs_updated = 0
|
||
failed_record_ids: list[int] = []
|
||
failed_records = 0
|
||
grouped_records: dict[str, list[dict]] = {}
|
||
|
||
if not records:
|
||
return {
|
||
'orders_created': 0,
|
||
'orders_updated': 0,
|
||
'jobs_created': 0,
|
||
'jobs_updated': 0,
|
||
'failed_records': 0,
|
||
'failed_record_ids': [],
|
||
'group_count': 0,
|
||
}
|
||
|
||
grouped_records = _group_external_records(records)
|
||
for external_order_id, group_records in grouped_records.items():
|
||
successful_records: list[dict] = []
|
||
for record in group_records:
|
||
try:
|
||
_ensure_external_product(
|
||
merchant=merchant,
|
||
external_product_name=str(record.get('YanSe') or '').strip(),
|
||
category=category,
|
||
)
|
||
except Exception as exc:
|
||
error_message = str(exc)
|
||
logger.warning(
|
||
'外部印染记录产品处理失败: external_record_id=%s external_order_id=%s error=%s',
|
||
record.get('ID'),
|
||
external_order_id,
|
||
error_message,
|
||
)
|
||
record_failure(record=record, error=error_message)
|
||
failed_records += 1
|
||
failed_record_ids.append(
|
||
_extract_positive_int(record.get('ID'), field_name='ID', allow_blank=False)
|
||
)
|
||
continue
|
||
successful_records.append(record)
|
||
|
||
if not successful_records:
|
||
continue
|
||
|
||
try:
|
||
order, order_created = _upsert_external_printing_order(
|
||
merchant=merchant,
|
||
external_order_id=external_order_id,
|
||
group_records=successful_records,
|
||
sync_user=sync_user,
|
||
)
|
||
if order_created:
|
||
orders_created += 1
|
||
else:
|
||
orders_updated += 1
|
||
except Exception as exc:
|
||
error_message = str(exc)
|
||
logger.warning(
|
||
'外部印染记录订单处理失败: external_order_id=%s error=%s',
|
||
external_order_id,
|
||
error_message,
|
||
)
|
||
for record in successful_records:
|
||
record_failure(record=record, error=error_message)
|
||
failed_records += 1
|
||
failed_record_ids.append(
|
||
_extract_positive_int(record.get('ID'), field_name='ID', allow_blank=False)
|
||
)
|
||
continue
|
||
|
||
for record in successful_records:
|
||
try:
|
||
_job, job_created = _upsert_external_printing_job(
|
||
merchant=merchant,
|
||
printing_order=order,
|
||
record=record,
|
||
sync_user=sync_user,
|
||
category=category,
|
||
)
|
||
if job_created:
|
||
jobs_created += 1
|
||
else:
|
||
jobs_updated += 1
|
||
except Exception as exc:
|
||
error_message = str(exc)
|
||
logger.warning(
|
||
'外部印染记录任务处理失败: external_record_id=%s external_order_id=%s error=%s',
|
||
record.get('ID'),
|
||
external_order_id,
|
||
error_message,
|
||
)
|
||
record_failure(record=record, error=error_message)
|
||
failed_records += 1
|
||
failed_record_ids.append(
|
||
_extract_positive_int(record.get('ID'), field_name='ID', allow_blank=False)
|
||
)
|
||
|
||
return {
|
||
'orders_created': orders_created,
|
||
'orders_updated': orders_updated,
|
||
'jobs_created': jobs_created,
|
||
'jobs_updated': jobs_updated,
|
||
'failed_records': failed_records,
|
||
'failed_record_ids': failed_record_ids,
|
||
'group_count': len(grouped_records),
|
||
}
|
||
|
||
|
||
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,
|
||
run_date_text: str | None = None,
|
||
record_ids: list[int] | None = None,
|
||
) -> dict:
|
||
if limit is not None:
|
||
limit = max(1, int(limit))
|
||
record_ids = record_ids or []
|
||
|
||
sync_user = _get_printing_sync_user()
|
||
merchant = sync_user.employee.merchant
|
||
category = _get_printing_sync_product_category(merchant)
|
||
|
||
failures_qs = api_models.PrintingExternalSyncFailure.objects.all().order_by(
|
||
'external_record_id',
|
||
'-created_at',
|
||
'-id',
|
||
)
|
||
if run_date_text:
|
||
failures_qs = failures_qs.filter(run_date=date.fromisoformat(run_date_text))
|
||
if record_ids:
|
||
failures_qs = failures_qs.filter(external_record_id__in=record_ids)
|
||
|
||
selected_failures = []
|
||
selected_record_ids = set()
|
||
for failure in failures_qs:
|
||
if failure.external_record_id in selected_record_ids:
|
||
continue
|
||
selected_failures.append(failure)
|
||
selected_record_ids.add(failure.external_record_id)
|
||
if limit is not None and len(selected_failures) >= limit:
|
||
break
|
||
|
||
if not selected_failures:
|
||
return {'limit': limit, 'retried_records': 0, 'message': '没有可重试的失败记录'}
|
||
|
||
retry_records: list[dict] = []
|
||
failure_by_record_id: dict[int, api_models.PrintingExternalSyncFailure] = {}
|
||
skipped_records = 0
|
||
|
||
for failure in selected_failures:
|
||
raw = failure.raw or {}
|
||
if not isinstance(raw, dict) or not raw:
|
||
failure.error = '失败记录缺少原始 raw 数据,无法重试'
|
||
failure.attempts = int(failure.attempts or 0) + 1
|
||
failure.last_attempt_at = timezone.now()
|
||
failure.save(update_fields=['error', 'attempts', 'last_attempt_at', 'updated_at'])
|
||
skipped_records += 1
|
||
continue
|
||
|
||
retry_records.append(raw)
|
||
failure_by_record_id[failure.external_record_id] = failure
|
||
|
||
def _update_failure(*, record: dict, error: str):
|
||
external_record_id = int(record.get('ID') or 0)
|
||
failure = failure_by_record_id.get(external_record_id)
|
||
if not failure:
|
||
return
|
||
failure.error = error or ''
|
||
failure.raw = record
|
||
failure.attempts = int(failure.attempts or 0) + 1
|
||
failure.last_attempt_at = timezone.now()
|
||
failure.save(update_fields=['error', 'raw', 'attempts', 'last_attempt_at', 'updated_at'])
|
||
|
||
batch_result = _sync_external_printing_records_batch(
|
||
records=retry_records,
|
||
merchant=merchant,
|
||
sync_user=sync_user,
|
||
category=category,
|
||
record_failure=_update_failure,
|
||
)
|
||
|
||
failed_ids = set(batch_result['failed_record_ids'])
|
||
succeeded_ids = [record_id for record_id in failure_by_record_id.keys() if record_id not in failed_ids]
|
||
|
||
deleted_failures = 0
|
||
if succeeded_ids:
|
||
deleted_failures, _deleted_detail = api_models.PrintingExternalSyncFailure.objects.filter(
|
||
external_record_id__in=succeeded_ids
|
||
).delete()
|
||
|
||
remaining_failures = api_models.PrintingExternalSyncFailure.objects.count()
|
||
|
||
return {
|
||
'limit': limit,
|
||
'retried_records': len(retry_records),
|
||
'skipped_records': skipped_records,
|
||
'orders_created': batch_result['orders_created'],
|
||
'orders_updated': batch_result['orders_updated'],
|
||
'jobs_created': batch_result['jobs_created'],
|
||
'jobs_updated': batch_result['jobs_updated'],
|
||
'failed_records': batch_result['failed_records'],
|
||
'failed_record_ids': batch_result['failed_record_ids'],
|
||
'deleted_failures': deleted_failures,
|
||
'remaining_failures': remaining_failures,
|
||
}
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def retry_external_printing_sync_failures(
|
||
self,
|
||
limit: int = 100,
|
||
run_date_text: str | None = None,
|
||
record_ids: list[int] | None = None,
|
||
):
|
||
payload = retry_external_printing_sync_failures_impl(
|
||
limit=max(1, int(limit or 100)),
|
||
run_date_text=run_date_text,
|
||
record_ids=record_ids or [],
|
||
)
|
||
payload['task_id'] = self.request.id
|
||
logger.info('重试外部印染同步失败记录完成: %s', payload)
|
||
return payload
|
||
|
||
|
||
def _run_fetch(page: int, page_size: int):
|
||
return asyncio.run(fetch_products_from_mingdaoyun(page=page, page_size=page_size))
|
||
|
||
|
||
def _run_fetch_customers(page: int, page_size: int):
|
||
return asyncio.run(fetch_customers_from_mingdaoyun(page=page, page_size=page_size))
|
||
|
||
|
||
def _upsert_product(product_data, merchant, category):
|
||
description = ''
|
||
if product_data.detail:
|
||
description = json.dumps(product_data.detail, ensure_ascii=False)
|
||
defaults = {
|
||
'category': category,
|
||
'name': product_data.name or product_data.uid,
|
||
'color': product_data.color or '',
|
||
'description': description,
|
||
'unit': _map_unit(product_data.unit),
|
||
'from_mdy': True,
|
||
}
|
||
width_decimal = _ensure_decimal(product_data.width)
|
||
if width_decimal is not None:
|
||
defaults['width_size'] = width_decimal
|
||
pieces_int = _ensure_int(product_data.pieces)
|
||
if pieces_int is not None:
|
||
defaults['pieces'] = pieces_int
|
||
segment_decimal = _ensure_decimal(product_data.segment_size)
|
||
if segment_decimal is not None:
|
||
defaults['segment_size'] = segment_decimal
|
||
|
||
# 使用 filter().first() 替代 get_or_create,容忍重复数据
|
||
product_obj = basic_models.Product.objects.filter(
|
||
merchant=merchant,
|
||
human_id=product_data.uid,
|
||
).first()
|
||
|
||
if product_obj is None:
|
||
# 不存在,创建新记录
|
||
product_obj = basic_models.Product.objects.create(
|
||
merchant=merchant,
|
||
human_id=product_data.uid,
|
||
**defaults,
|
||
)
|
||
return True
|
||
|
||
if not product_obj.from_mdy:
|
||
return False
|
||
|
||
updated = False
|
||
for field, value in defaults.items():
|
||
if getattr(product_obj, field) != value:
|
||
setattr(product_obj, field, value)
|
||
updated = True
|
||
if updated:
|
||
product_obj.save(update_fields=list(defaults.keys()))
|
||
return updated
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_mdy_products(
|
||
self,
|
||
page_size: int = 300,
|
||
max_pages: int | None = None,
|
||
max_records: int | None = None,
|
||
):
|
||
"""
|
||
从明道云同步产品数据(按 ctime 升序分页扫描)。
|
||
|
||
- 默认从上次同步记录的 page_index 继续翻页
|
||
- last_ctime/last_rowid 用于页内游标(避免重复处理)
|
||
- max_pages 表示“单次任务最多处理多少页”(不是最大页码)
|
||
"""
|
||
merchant = _get_mdy_merchant()
|
||
category = _get_mdy_category(merchant)
|
||
max_records = max_records or 0 # 0 表示不限制
|
||
|
||
last_sync = api_models.DataSync.objects.filter(
|
||
table_name=api_models.DataSync.TableName.PRODUCT,
|
||
merchant__isnull=True,
|
||
).order_by('-created_at').first()
|
||
last_ctime = last_sync.last_ctime if last_sync else None
|
||
last_rowid = last_sync.last_rowid if last_sync else ''
|
||
start_page_index = last_sync.page_index if last_sync else 1
|
||
|
||
synced_rows = 0
|
||
page_index = max(1, start_page_index)
|
||
pages_processed = 0
|
||
total_count = 0
|
||
latest_ctime = last_ctime
|
||
latest_rowid = last_rowid
|
||
|
||
while True:
|
||
# max_pages: 单次任务最多处理多少页(不是“最大页码”)
|
||
if max_pages is not None and pages_processed >= max_pages:
|
||
break
|
||
if max_records and synced_rows >= max_records:
|
||
break
|
||
|
||
products, total = _run_fetch(page_index, page_size)
|
||
total_count = total
|
||
if not products:
|
||
break
|
||
|
||
hit_max_records = False
|
||
for item in products:
|
||
if max_records and synced_rows >= max_records:
|
||
hit_max_records = True
|
||
break
|
||
product_ctime = _parse_mdy_datetime(item.created_at)
|
||
|
||
# 跳过已同步到的游标
|
||
if last_ctime and product_ctime:
|
||
if product_ctime < last_ctime:
|
||
continue
|
||
if product_ctime == last_ctime and last_rowid and item.rowid == last_rowid:
|
||
continue
|
||
|
||
changed = _upsert_product(item, merchant, category)
|
||
if changed:
|
||
synced_rows += 1
|
||
if product_ctime:
|
||
if latest_ctime is None or product_ctime > latest_ctime:
|
||
latest_ctime = product_ctime
|
||
latest_rowid = item.rowid
|
||
elif product_ctime == latest_ctime:
|
||
# 同一秒内可能有多条记录,尽量把 rowid 推进到最后处理的那条
|
||
latest_rowid = item.rowid
|
||
|
||
pages_processed += 1
|
||
if hit_max_records:
|
||
# 达到单次任务的记录上限:下次从同一页继续(依赖 last_ctime/last_rowid 跳过已处理部分)
|
||
break
|
||
|
||
# 若返回不足一页,说明到尾部,可结束
|
||
if len(products) < page_size:
|
||
break
|
||
page_index += 1
|
||
|
||
record_last_ctime = latest_ctime or last_ctime
|
||
record_last_rowid = latest_rowid or last_rowid
|
||
api_models.DataSync.objects.create(
|
||
table_name=api_models.DataSync.TableName.PRODUCT,
|
||
merchant=None,
|
||
page_index=page_index,
|
||
page_size=page_size,
|
||
synced_rows=synced_rows,
|
||
total_count=total_count,
|
||
last_ctime=record_last_ctime,
|
||
last_rowid=record_last_rowid,
|
||
note='asc scan',
|
||
)
|
||
|
||
payload = {
|
||
'task_id': self.request.id,
|
||
'synced_rows': synced_rows,
|
||
'page_index': page_index,
|
||
'page_size': page_size,
|
||
'total_count': total_count,
|
||
'last_ctime': record_last_ctime.isoformat() if record_last_ctime else None,
|
||
}
|
||
logger.info('明道云产品同步完成: %s', payload)
|
||
return payload
|
||
|
||
|
||
def _upsert_customer(customer_data, merchant):
|
||
mdy_uid = customer_data.uid or customer_data.rowid
|
||
if not mdy_uid:
|
||
return False
|
||
defaults = {
|
||
'merchant': merchant,
|
||
'name': customer_data.name or customer_data.uid,
|
||
'area': customer_data.area or '',
|
||
'from_mdy': True,
|
||
}
|
||
customer_obj, created = basic_models.Customer.objects.get_or_create(
|
||
mdy_uid=mdy_uid,
|
||
defaults=defaults,
|
||
)
|
||
if created:
|
||
return True
|
||
|
||
if not customer_obj.from_mdy:
|
||
return False
|
||
|
||
updated_fields = {}
|
||
for field, value in defaults.items():
|
||
if getattr(customer_obj, field) != value:
|
||
updated_fields[field] = value
|
||
|
||
if updated_fields:
|
||
for field, value in updated_fields.items():
|
||
setattr(customer_obj, field, value)
|
||
customer_obj.save(update_fields=list(updated_fields.keys()))
|
||
return True
|
||
return False
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_mdy_customers(
|
||
self,
|
||
page_size: int = 300,
|
||
max_pages: int | None = None,
|
||
max_records: int | None = None,
|
||
):
|
||
"""
|
||
从明道云同步客户数据(按 ctime 升序分页扫描)。
|
||
|
||
- 默认从上次同步记录的 page_index 继续翻页
|
||
- last_ctime/last_rowid 用于页内游标(避免重复处理)
|
||
- max_pages 表示“单次任务最多处理多少页”(不是最大页码)
|
||
"""
|
||
merchant = _get_mdy_merchant()
|
||
max_records = max_records or 0 # 0 表示不限制
|
||
|
||
last_sync = api_models.DataSync.objects.filter(
|
||
table_name=api_models.DataSync.TableName.CUSTOMER,
|
||
merchant__isnull=True,
|
||
).order_by('-created_at').first()
|
||
last_ctime = last_sync.last_ctime if last_sync else None
|
||
last_rowid = last_sync.last_rowid if last_sync else ''
|
||
start_page_index = last_sync.page_index if last_sync else 1
|
||
|
||
synced_rows = 0
|
||
page_index = max(1, start_page_index)
|
||
pages_processed = 0
|
||
total_count = 0
|
||
latest_ctime = last_ctime
|
||
latest_rowid = last_rowid
|
||
|
||
while True:
|
||
# max_pages: 单次任务最多处理多少页(不是“最大页码”)
|
||
if max_pages is not None and pages_processed >= max_pages:
|
||
break
|
||
if max_records and synced_rows >= max_records:
|
||
break
|
||
|
||
customers, total = _run_fetch_customers(page_index, page_size)
|
||
total_count = total
|
||
if not customers:
|
||
break
|
||
|
||
hit_max_records = False
|
||
for item in customers:
|
||
if max_records and synced_rows >= max_records:
|
||
hit_max_records = True
|
||
break
|
||
record_ctime = _parse_mdy_datetime(item.created_at)
|
||
|
||
if last_ctime and record_ctime:
|
||
if record_ctime < last_ctime:
|
||
continue
|
||
if record_ctime == last_ctime and last_rowid and item.rowid == last_rowid:
|
||
continue
|
||
|
||
changed = _upsert_customer(item, merchant)
|
||
if changed:
|
||
synced_rows += 1
|
||
if record_ctime:
|
||
if latest_ctime is None or record_ctime > latest_ctime:
|
||
latest_ctime = record_ctime
|
||
latest_rowid = item.rowid
|
||
elif record_ctime == latest_ctime:
|
||
latest_rowid = item.rowid
|
||
|
||
pages_processed += 1
|
||
if hit_max_records:
|
||
break
|
||
|
||
if len(customers) < page_size:
|
||
break
|
||
page_index += 1
|
||
|
||
record_last_ctime = latest_ctime or last_ctime
|
||
record_last_rowid = latest_rowid or last_rowid
|
||
api_models.DataSync.objects.create(
|
||
table_name=api_models.DataSync.TableName.CUSTOMER,
|
||
merchant=None,
|
||
page_index=page_index,
|
||
page_size=page_size,
|
||
synced_rows=synced_rows,
|
||
total_count=total_count,
|
||
last_ctime=record_last_ctime,
|
||
last_rowid=record_last_rowid,
|
||
note='asc scan',
|
||
)
|
||
|
||
payload = {
|
||
'task_id': self.request.id,
|
||
'synced_rows': synced_rows,
|
||
'page_index': page_index,
|
||
'page_size': page_size,
|
||
'total_count': total_count,
|
||
'last_ctime': record_last_ctime.isoformat() if record_last_ctime else None,
|
||
}
|
||
logger.info('明道云客户同步完成: %s', payload)
|
||
return payload
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_mdy_plate_orders(
|
||
self,
|
||
page_size: int = 300,
|
||
max_pages: int | None = None,
|
||
max_records: int | None = None,
|
||
with_related: bool = True,
|
||
max_related_per_type: int = 5,
|
||
request_interval_seconds: float = 0.02,
|
||
):
|
||
"""从明道云同步开版数据表到暂存表(可选抓取跨表关联数据)。"""
|
||
|
||
payload = sync_mdy_plate_orders_to_staging(
|
||
page_size=page_size,
|
||
max_pages=max_pages,
|
||
max_records=max_records,
|
||
with_related=with_related,
|
||
max_related_per_type=max_related_per_type,
|
||
request_interval_seconds=request_interval_seconds,
|
||
merchant_id=None,
|
||
)
|
||
payload["task_id"] = self.request.id
|
||
return payload
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_mdy_products_for_merchant(
|
||
self,
|
||
*,
|
||
merchant_id: int,
|
||
page_size: int = 300,
|
||
max_pages: int | None = None,
|
||
max_records: int | None = None,
|
||
):
|
||
"""按商户隔离同步产品数据(独立游标)。"""
|
||
merchant = _get_mdy_merchant(merchant_id=merchant_id)
|
||
category = _get_mdy_category(merchant)
|
||
max_records = max_records or 0
|
||
|
||
last_sync = api_models.DataSync.objects.filter(
|
||
table_name=api_models.DataSync.TableName.PRODUCT,
|
||
merchant=merchant,
|
||
).order_by('-created_at').first()
|
||
last_ctime = last_sync.last_ctime if last_sync else None
|
||
last_rowid = last_sync.last_rowid if last_sync else ''
|
||
start_page_index = last_sync.page_index if last_sync else 1
|
||
|
||
synced_rows = 0
|
||
page_index = max(1, start_page_index)
|
||
pages_processed = 0
|
||
total_count = 0
|
||
latest_ctime = last_ctime
|
||
latest_rowid = last_rowid
|
||
|
||
while True:
|
||
if max_pages is not None and pages_processed >= max_pages:
|
||
break
|
||
if max_records and synced_rows >= max_records:
|
||
break
|
||
|
||
products, total = _run_fetch(page_index, page_size)
|
||
total_count = total
|
||
if not products:
|
||
break
|
||
|
||
hit_max_records = False
|
||
for item in products:
|
||
if max_records and synced_rows >= max_records:
|
||
hit_max_records = True
|
||
break
|
||
product_ctime = _parse_mdy_datetime(item.created_at)
|
||
|
||
if last_ctime and product_ctime:
|
||
if product_ctime < last_ctime:
|
||
continue
|
||
if product_ctime == last_ctime and last_rowid and item.rowid == last_rowid:
|
||
continue
|
||
|
||
changed = _upsert_product(item, merchant, category)
|
||
if changed:
|
||
synced_rows += 1
|
||
if product_ctime:
|
||
if latest_ctime is None or product_ctime > latest_ctime:
|
||
latest_ctime = product_ctime
|
||
latest_rowid = item.rowid
|
||
elif product_ctime == latest_ctime:
|
||
latest_rowid = item.rowid
|
||
|
||
pages_processed += 1
|
||
if hit_max_records:
|
||
break
|
||
|
||
if len(products) < page_size:
|
||
break
|
||
page_index += 1
|
||
|
||
record_last_ctime = latest_ctime or last_ctime
|
||
record_last_rowid = latest_rowid or last_rowid
|
||
api_models.DataSync.objects.create(
|
||
table_name=api_models.DataSync.TableName.PRODUCT,
|
||
merchant=merchant,
|
||
page_index=page_index,
|
||
page_size=page_size,
|
||
synced_rows=synced_rows,
|
||
total_count=total_count,
|
||
last_ctime=record_last_ctime,
|
||
last_rowid=record_last_rowid,
|
||
note='asc scan (merchant isolated)',
|
||
)
|
||
|
||
payload = {
|
||
'task_id': self.request.id,
|
||
'merchant_id': merchant.id,
|
||
'synced_rows': synced_rows,
|
||
'page_index': page_index,
|
||
'page_size': page_size,
|
||
'total_count': total_count,
|
||
'last_ctime': record_last_ctime.isoformat() if record_last_ctime else None,
|
||
}
|
||
logger.info('明道云产品同步完成(merchant=%s): %s', merchant.id, payload)
|
||
return payload
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_mdy_customers_for_merchant(
|
||
self,
|
||
*,
|
||
merchant_id: int,
|
||
page_size: int = 300,
|
||
max_pages: int | None = None,
|
||
max_records: int | None = None,
|
||
):
|
||
"""按商户隔离同步客户数据(独立游标)。"""
|
||
merchant = _get_mdy_merchant(merchant_id=merchant_id)
|
||
max_records = max_records or 0
|
||
|
||
last_sync = api_models.DataSync.objects.filter(
|
||
table_name=api_models.DataSync.TableName.CUSTOMER,
|
||
merchant=merchant,
|
||
).order_by('-created_at').first()
|
||
last_ctime = last_sync.last_ctime if last_sync else None
|
||
last_rowid = last_sync.last_rowid if last_sync else ''
|
||
start_page_index = last_sync.page_index if last_sync else 1
|
||
|
||
synced_rows = 0
|
||
page_index = max(1, start_page_index)
|
||
pages_processed = 0
|
||
total_count = 0
|
||
latest_ctime = last_ctime
|
||
latest_rowid = last_rowid
|
||
|
||
while True:
|
||
if max_pages is not None and pages_processed >= max_pages:
|
||
break
|
||
if max_records and synced_rows >= max_records:
|
||
break
|
||
|
||
customers, total = _run_fetch_customers(page_index, page_size)
|
||
total_count = total
|
||
if not customers:
|
||
break
|
||
|
||
hit_max_records = False
|
||
for item in customers:
|
||
if max_records and synced_rows >= max_records:
|
||
hit_max_records = True
|
||
break
|
||
record_ctime = _parse_mdy_datetime(item.created_at)
|
||
|
||
if last_ctime and record_ctime:
|
||
if record_ctime < last_ctime:
|
||
continue
|
||
if record_ctime == last_ctime and last_rowid and item.rowid == last_rowid:
|
||
continue
|
||
|
||
changed = _upsert_customer(item, merchant)
|
||
if changed:
|
||
synced_rows += 1
|
||
if record_ctime:
|
||
if latest_ctime is None or record_ctime > latest_ctime:
|
||
latest_ctime = record_ctime
|
||
latest_rowid = item.rowid
|
||
elif record_ctime == latest_ctime:
|
||
latest_rowid = item.rowid
|
||
|
||
pages_processed += 1
|
||
if hit_max_records:
|
||
break
|
||
|
||
if len(customers) < page_size:
|
||
break
|
||
page_index += 1
|
||
|
||
record_last_ctime = latest_ctime or last_ctime
|
||
record_last_rowid = latest_rowid or last_rowid
|
||
api_models.DataSync.objects.create(
|
||
table_name=api_models.DataSync.TableName.CUSTOMER,
|
||
merchant=merchant,
|
||
page_index=page_index,
|
||
page_size=page_size,
|
||
synced_rows=synced_rows,
|
||
total_count=total_count,
|
||
last_ctime=record_last_ctime,
|
||
last_rowid=record_last_rowid,
|
||
note='asc scan (merchant isolated)',
|
||
)
|
||
|
||
payload = {
|
||
'task_id': self.request.id,
|
||
'merchant_id': merchant.id,
|
||
'synced_rows': synced_rows,
|
||
'page_index': page_index,
|
||
'page_size': page_size,
|
||
'total_count': total_count,
|
||
'last_ctime': record_last_ctime.isoformat() if record_last_ctime else None,
|
||
}
|
||
logger.info('明道云客户同步完成(merchant=%s): %s', merchant.id, payload)
|
||
return payload
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_mdy_plate_orders_for_merchant(
|
||
self,
|
||
*,
|
||
merchant_id: int,
|
||
page_size: int = 300,
|
||
max_pages: int | None = None,
|
||
max_records: int | None = None,
|
||
with_related: bool = True,
|
||
max_related_per_type: int = 5,
|
||
request_interval_seconds: float = 0.02,
|
||
):
|
||
"""按商户隔离同步开版暂存表(独立游标)。"""
|
||
payload = sync_mdy_plate_orders_to_staging(
|
||
page_size=page_size,
|
||
max_pages=max_pages,
|
||
max_records=max_records,
|
||
with_related=with_related,
|
||
max_related_per_type=max_related_per_type,
|
||
request_interval_seconds=request_interval_seconds,
|
||
merchant_id=merchant_id,
|
||
)
|
||
payload["task_id"] = self.request.id
|
||
payload["merchant_id"] = merchant_id
|
||
return payload
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def sync_external_printing_records(self, limit: int = 100, advance_cursor: bool = True):
|
||
"""
|
||
从外部 records 接口增量同步印染订单/任务。
|
||
|
||
策略:
|
||
- 拉取时强制 update_cursor=false
|
||
- 按外部 record.ID 处理失败并记录
|
||
- 默认在本批处理结束后推进远端 cursor;本地检查可传 advance_cursor=False 禁止推进
|
||
"""
|
||
limit = max(1, int(limit or 100))
|
||
sync_user = _get_printing_sync_user()
|
||
merchant = sync_user.employee.merchant
|
||
category = _get_printing_sync_product_category(merchant)
|
||
payload = _fetch_external_printing_records(limit=limit)
|
||
records = payload.get('records', [])
|
||
last_record_id = payload.get('last_record_id')
|
||
run_date = timezone.localdate()
|
||
|
||
if records:
|
||
batch_result = _sync_external_printing_records_batch(
|
||
records=records,
|
||
merchant=merchant,
|
||
sync_user=sync_user,
|
||
category=category,
|
||
record_failure=lambda *, record, error: _record_printing_external_sync_failure(
|
||
run_date=run_date,
|
||
record=record,
|
||
error=error,
|
||
),
|
||
)
|
||
|
||
cursor_payload = None
|
||
if advance_cursor and last_record_id is not None:
|
||
cursor_payload = _advance_external_printing_cursor(cursor_value=int(last_record_id))
|
||
else:
|
||
batch_result = {
|
||
'orders_created': 0,
|
||
'orders_updated': 0,
|
||
'jobs_created': 0,
|
||
'jobs_updated': 0,
|
||
'failed_records': 0,
|
||
'failed_record_ids': [],
|
||
'group_count': 0,
|
||
}
|
||
cursor_payload = None
|
||
|
||
api_models.DataSync.objects.create(
|
||
table_name=api_models.DataSync.TableName.PRINTING_EXTERNAL_RECORD,
|
||
merchant=merchant,
|
||
page_index=1,
|
||
page_size=limit,
|
||
synced_rows=len(records),
|
||
total_count=int(payload.get('count') or 0),
|
||
note=f'cursor_after={payload.get("cursor_after")} last_record_id={last_record_id}',
|
||
)
|
||
|
||
result = {
|
||
'task_id': self.request.id,
|
||
'limit': limit,
|
||
'mode': payload.get('mode'),
|
||
'count': len(records),
|
||
'orders_created': batch_result['orders_created'],
|
||
'orders_updated': batch_result['orders_updated'],
|
||
'jobs_created': batch_result['jobs_created'],
|
||
'jobs_updated': batch_result['jobs_updated'],
|
||
'failed_records': batch_result['failed_records'],
|
||
'failed_record_ids': batch_result['failed_record_ids'],
|
||
'last_record_id': last_record_id,
|
||
'cursor_before': payload.get('cursor_before'),
|
||
'cursor_after': payload.get('cursor_after'),
|
||
'cursor_updated': bool(cursor_payload),
|
||
'advance_cursor': bool(advance_cursor),
|
||
'group_count': batch_result['group_count'],
|
||
}
|
||
logger.info('外部印染 records 同步完成: %s', result)
|
||
return result
|
||
|
||
|
||
def _record_mdy_plate_order_staging_tiia_failure(
|
||
*,
|
||
run_date,
|
||
staging: api_models.MDYPlateOrderStaging,
|
||
error: str,
|
||
details: list[dict] | None = None,
|
||
) -> None:
|
||
"""
|
||
记录“开版暂存 -> TIIA 上传”失败(同一天同一 mdy_rowid 去重,attempts 累加)。
|
||
"""
|
||
obj, _created = api_models.MDYPlateOrderStagingTiiaUploadFailure.objects.get_or_create(
|
||
run_date=run_date,
|
||
mdy_rowid=staging.mdy_rowid,
|
||
defaults={
|
||
"staging_id": staging.id,
|
||
"error": error or "",
|
||
"details": details or [],
|
||
"attempts": 0,
|
||
"last_attempt_at": timezone.now(),
|
||
},
|
||
)
|
||
obj.staging_id = staging.id
|
||
obj.error = error or ""
|
||
obj.details = details or []
|
||
obj.attempts = int(obj.attempts or 0) + 1
|
||
obj.last_attempt_at = timezone.now()
|
||
obj.save(update_fields=["staging_id", "error", "details", "attempts", "last_attempt_at", "updated_at"])
|
||
|
||
|
||
@shared_task(bind=True)
|
||
def upload_mdy_plate_order_staging_images_to_tencent_tiia(
|
||
self,
|
||
batch_size: int = 200,
|
||
*,
|
||
dry_run: bool = False,
|
||
):
|
||
"""
|
||
将 MDYPlateOrderStaging 的“开版图(Attachment)”上传到腾讯云 TIIA 图库(增量)。
|
||
|
||
- 游标:使用 api_data_sync.last_rowid 存储 staging 表的 last_id(自增主键)
|
||
- 失败:写入 api_mdy_plate_order_staging_tiia_upload_failure(不影响后续继续跑)
|
||
- 限流:按 settings.TENCENTCLOUD_TIIA_QPS(默认 10 qps)
|
||
"""
|
||
run_date = timezone.localdate()
|
||
limiter = build_default_tiia_rate_limiter()
|
||
|
||
last_sync = (
|
||
api_models.DataSync.objects.filter(
|
||
table_name=api_models.DataSync.TableName.MDY_PLATE_ORDER_STAGING_TIIA_UPLOAD
|
||
)
|
||
.order_by("-created_at")
|
||
.first()
|
||
)
|
||
last_id = 0
|
||
if last_sync and (last_sync.last_rowid or "").strip():
|
||
try:
|
||
last_id = int(last_sync.last_rowid)
|
||
except ValueError:
|
||
last_id = 0
|
||
|
||
qs = api_models.MDYPlateOrderStaging.objects.filter(id__gt=last_id).order_by("id")
|
||
if batch_size and batch_size > 0:
|
||
qs = qs[:batch_size]
|
||
|
||
processed = 0
|
||
failed_records = 0
|
||
latest_id = last_id
|
||
latest_ctime = last_sync.last_ctime if last_sync else None
|
||
|
||
for staging in qs:
|
||
processed += 1
|
||
latest_id = staging.id
|
||
if staging.ctime:
|
||
latest_ctime = staging.ctime
|
||
|
||
try:
|
||
result = upload_mdy_plate_order_staging_plate_images_to_tencent_tiia(
|
||
staging=staging,
|
||
rate_limiter=limiter,
|
||
dry_run=dry_run,
|
||
)
|
||
if int(result.get("failure_count") or 0) > 0:
|
||
failed_records += 1
|
||
_record_mdy_plate_order_staging_tiia_failure(
|
||
run_date=run_date,
|
||
staging=staging,
|
||
error="部分图片上传失败" if int(result.get("success_count") or 0) > 0 else "图片上传失败",
|
||
details=[{"result": result}],
|
||
)
|
||
except Exception as exc:
|
||
failed_records += 1
|
||
_record_mdy_plate_order_staging_tiia_failure(
|
||
run_date=run_date,
|
||
staging=staging,
|
||
error=str(exc),
|
||
details=[{"error": str(exc), "mdy_rowid": staging.mdy_rowid, "staging_id": staging.id}],
|
||
)
|
||
|
||
api_models.DataSync.objects.create(
|
||
table_name=api_models.DataSync.TableName.MDY_PLATE_ORDER_STAGING_TIIA_UPLOAD,
|
||
page_index=1,
|
||
page_size=batch_size,
|
||
synced_rows=processed,
|
||
total_count=api_models.MDYPlateOrderStaging.objects.count(),
|
||
last_ctime=latest_ctime,
|
||
last_rowid=str(latest_id) if latest_id else str(last_id),
|
||
note=f"id scan; dry_run={dry_run}",
|
||
)
|
||
|
||
payload = {
|
||
"task_id": self.request.id,
|
||
"processed": processed,
|
||
"failed_records": failed_records,
|
||
"last_id": latest_id,
|
||
"last_ctime": latest_ctime.isoformat() if latest_ctime else None,
|
||
"dry_run": dry_run,
|
||
"batch_size": batch_size,
|
||
}
|
||
logger.info("MDY 开版暂存图片上传到 TIIA 完成: %s", payload)
|
||
return payload
|
||
|
||
|
||
@shared_task
|
||
def save_api_audit_log(
|
||
url: str,
|
||
method: str,
|
||
request_data: dict,
|
||
query_params: dict,
|
||
user_id: int | None,
|
||
username: str,
|
||
response_status: int | None,
|
||
):
|
||
"""
|
||
异步保存API审计日志。
|
||
|
||
通过Celery队列异步执行,避免阻塞API响应。
|
||
|
||
参数:
|
||
url: 请求URL路径
|
||
method: HTTP方法(POST等)
|
||
request_data: 请求体数据(JSON格式)
|
||
query_params: URL查询参数
|
||
user_id: 操作用户ID
|
||
username: 操作用户的用户名
|
||
response_status: HTTP响应状态码
|
||
"""
|
||
try:
|
||
api_models.ApiAuditLog.objects.create(
|
||
url=url,
|
||
method=method,
|
||
request_data=request_data,
|
||
query_params=query_params,
|
||
user_id=user_id,
|
||
username=username,
|
||
response_status=response_status,
|
||
)
|
||
logger.debug('API审计日志已保存: %s %s (user=%s, status=%s)', method, url, username, response_status)
|
||
except Exception as e:
|
||
logger.error('保存API审计日志失败: %s', str(e), exc_info=True)
|