1
0
forked from erp-dev/erp

fix: create customer when external_order sync to database

This commit is contained in:
2026-03-24 22:14:41 +08:00
parent b48bfc50a5
commit 315b53f5f7
4 changed files with 362 additions and 111 deletions

View File

@@ -35,7 +35,7 @@ class Command(BaseCommand):
if product_id:
queryset = queryset.filter(id=product_id)
queryset = queryset.order_by('id')
queryset = queryset.order_by('-id')
limit = options.get('limit')
if limit is not None:

View File

@@ -0,0 +1,125 @@
from datetime import date
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from api_v1 import models as api_models
from api_v1.tasks import (
_get_printing_sync_product_category,
_get_printing_sync_user,
_sync_external_printing_records_batch,
)
class Command(BaseCommand):
help = '基于失败记录重试外部印染 records 同步'
def add_arguments(self, parser):
parser.add_argument('--limit', type=int, default=100, help='最多重试多少条失败记录')
parser.add_argument(
'--run-date',
type=str,
default=None,
help='仅重试指定任务日期的失败记录,格式 YYYY-MM-DD',
)
parser.add_argument(
'--record-id',
action='append',
type=int,
default=None,
help='仅重试指定 external_record_id可多次传入',
)
def handle(self, *args, **options):
limit = max(1, int(options.get('limit') or 100))
run_date_text = options.get('run_date')
record_ids = options.get('record_id') 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:
try:
failures_qs = failures_qs.filter(run_date=date.fromisoformat(run_date_text))
except ValueError as exc:
raise CommandError(f'--run-date 格式非法: {run_date_text}') from exc
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 len(selected_failures) >= limit:
break
if not selected_failures:
self.stdout.write(self.style.SUCCESS(str({'retried_records': 0, 'message': '没有可重试的失败记录'})))
return
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()
result = {
'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,
}
self.stdout.write(self.style.SUCCESS(str(result)))

View File

@@ -330,7 +330,22 @@ def _resolve_external_customer(*, merchant, record: dict):
.first()
)
if not customer:
raise RuntimeError(f'未找到客户: {customer_name}')
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
@@ -490,7 +505,12 @@ def _build_external_order_data(
sync_user,
):
first = group_records[0]
customer, external_customer_name, external_customer_id = _resolve_external_customer(merchant=merchant, record=first)
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(),
@@ -594,6 +614,126 @@ def _upsert_external_printing_job(*, merchant, printing_order, record: dict, syn
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 _run_fetch(page: int, page_size: int):
return asyncio.run(fetch_products_from_mingdaoyun(page=page, page_size=page_size))
@@ -1158,113 +1298,32 @@ def sync_external_printing_records(self, limit: int = 100):
last_record_id = payload.get('last_record_id')
run_date = timezone.localdate()
orders_created = 0
orders_updated = 0
jobs_created = 0
jobs_updated = 0
failed_record_ids: list[int] = []
failed_records = 0
if records:
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_printing_external_sync_failure(
run_date=run_date,
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_printing_external_sync_failure(
run_date=run_date,
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_printing_external_sync_failure(
run_date=run_date,
record=record,
error=error_message,
)
failed_records += 1
failed_record_ids.append(
_extract_positive_int(record.get('ID'), field_name='ID', allow_blank=False)
)
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 last_record_id is not None:
cursor_payload = _advance_external_printing_cursor(cursor_value=int(last_record_id))
else:
grouped_records = {}
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(
@@ -1282,17 +1341,17 @@ def sync_external_printing_records(self, limit: int = 100):
'limit': limit,
'mode': payload.get('mode'),
'count': len(records),
'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,
'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),
'group_count': len(grouped_records),
'group_count': batch_result['group_count'],
}
logger.info('外部印染 records 同步完成: %s', result)
return result

View File

@@ -1,8 +1,11 @@
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from api_v1 import models as api_models
from api_v1.tasks import sync_external_printing_records
@@ -224,6 +227,70 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
self.assertEqual(failure.external_order_id, 'KD20432358')
self.assertIn('外部图片不存在', failure.error)
@patch('api_v1.tasks._advance_external_printing_cursor')
@patch('api_v1.tasks._fetch_external_product_image')
@patch('api_v1.tasks._fetch_external_printing_records')
def test_missing_customer_creates_customer_and_syncs_successfully(
self,
mock_fetch_records,
mock_fetch_image,
mock_advance_cursor,
):
missing_customer_name = '新补建客户'
record = self._build_record(record_id=1000002, product_name=self.existing_product.name)
record['customer']['KhName'] = missing_customer_name
record['KhID'] = 'KH01000'
mock_fetch_records.return_value = self._build_payload([record])
mock_advance_cursor.return_value = {'updated': True}
result = sync_external_printing_records.run(limit=100)
self.assertEqual(result['failed_records'], 0)
self.assertEqual(result['jobs_created'], 1)
self.assertEqual(mock_fetch_image.call_count, 0)
customer = basic_models.Customer.objects.get(merchant=self.merchant, name=missing_customer_name)
self.assertEqual(customer.created_by, self.sync_employee)
order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358')
self.assertEqual(order.customer, customer)
self.assertEqual(order.external_customer_name, missing_customer_name)
def test_retry_command_replays_failure_raw_and_clears_failure_rows(self):
missing_customer_name = '重试补建客户'
raw_record = self._build_record(record_id=1000003, product_name=self.existing_product.name)
raw_record['customer']['KhName'] = missing_customer_name
raw_record['KhID'] = 'KH01001'
api_models.PrintingExternalSyncFailure.objects.create(
run_date=timezone.localdate(),
external_record_id=1000003,
external_order_id='KD20432358',
product_name=self.existing_product.name,
error=f'未找到客户: {missing_customer_name}',
raw=raw_record,
attempts=1,
last_attempt_at=timezone.now(),
)
output = StringIO()
call_command(
'retry_external_printing_sync_failures',
'--record-id',
'1000003',
stdout=output,
)
self.assertTrue(
basic_models.Customer.objects.filter(merchant=self.merchant, name=missing_customer_name).exists()
)
self.assertTrue(
printing_models.PrintingJob.objects.filter(original_id=1000003).exists()
)
self.assertFalse(
api_models.PrintingExternalSyncFailure.objects.filter(external_record_id=1000003).exists()
)
self.assertIn("'retried_records': 1", output.getvalue())
@patch('api_v1.tasks._advance_external_printing_cursor')
@patch('api_v1.tasks._fetch_external_product_image')
@patch('api_v1.tasks._fetch_external_printing_records')