1
0
forked from erp-dev/erp

feat: error_code + api doc + printing-job fields

This commit is contained in:
2026-07-06 23:13:57 +08:00
parent 740d23d04b
commit ddbf798665
21 changed files with 977 additions and 17 deletions

View File

@@ -0,0 +1,22 @@
from django.core.management.base import BaseCommand
from flower.app_version import set_cached_app_version_payload
class Command(BaseCommand):
help = "更新 App 最新版本信息缓存"
def add_arguments(self, parser):
parser.add_argument("--major", type=int, required=True)
parser.add_argument("--minor", type=int, required=True)
parser.add_argument("--build", type=int, required=True)
parser.add_argument("--download-url", required=True)
def handle(self, *args, **options):
payload = set_cached_app_version_payload(
major=options["major"],
minor=options["minor"],
build=options["build"],
download_url=options["download_url"],
)
self.stdout.write(self.style.SUCCESS(str(payload)))

View File

@@ -8,7 +8,15 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--limit', type=int, default=100)
parser.add_argument(
'--no-advance-cursor',
action='store_true',
help='只拉取并同步本批 records不推进外部 cursor。适合本地/测试环境检查。',
)
def handle(self, *args, **options):
payload = sync_external_printing_records.run(limit=options['limit'])
payload = sync_external_printing_records.run(
limit=options['limit'],
advance_cursor=not options['no_advance_cursor'],
)
self.stdout.write(self.style.SUCCESS(str(payload)))

View File

@@ -743,6 +743,10 @@ def _upload_product_image(product, image_payload: dict):
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,
@@ -811,23 +815,38 @@ def _ensure_external_product(*, merchant, external_product_name: str, category):
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
image_payload = _fetch_external_product_image(normalized)
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:
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)
@@ -1876,14 +1895,14 @@ def sync_mdy_plate_orders_for_merchant(
@shared_task(bind=True)
def sync_external_printing_records(self, limit: int = 100):
def sync_external_printing_records(self, limit: int = 100, advance_cursor: bool = True):
"""
从外部 records 接口增量同步印染订单/任务。
策略:
- 拉取时强制 update_cursor=false
- 按外部 record.ID 处理失败并记录
- 本批处理结束后无论是否有部分失败,都推进远端 cursor
- 默认在本批处理结束后推进远端 cursor;本地检查可传 advance_cursor=False 禁止推进
"""
limit = max(1, int(limit or 100))
sync_user = _get_printing_sync_user()
@@ -1908,7 +1927,7 @@ def sync_external_printing_records(self, limit: int = 100):
)
cursor_payload = None
if last_record_id is not 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 = {
@@ -1947,6 +1966,7 @@ def sync_external_printing_records(self, limit: int = 100):
'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)

View File

@@ -45,6 +45,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
merchant=self.merchant,
category=self.category,
name='Tj1712#12号色-24码',
image='product_images/existing.jpg',
)
state = State.objects.create(name='待生产')
@@ -232,6 +233,85 @@ 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_sync_can_skip_advancing_external_cursor(
self,
mock_fetch_records,
mock_fetch_image,
mock_advance_cursor,
):
mock_fetch_records.return_value = self._build_payload(
[self._build_record(record_id=1000100, product_name=self.existing_product.name)]
)
result = sync_external_printing_records.run(limit=100, advance_cursor=False)
self.assertEqual(result['jobs_created'], 1)
self.assertEqual(result['failed_records'], 0)
self.assertFalse(result['cursor_updated'])
self.assertFalse(result['advance_cursor'])
mock_advance_cursor.assert_not_called()
@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_white_fabric_image_does_not_block_sync_or_record_failure(
self,
mock_fetch_records,
mock_fetch_image,
mock_advance_cursor,
):
product_name = '加厚白布-25码'
mock_fetch_records.return_value = self._build_payload(
[self._build_record(record_id=1000101, product_name=product_name)]
)
mock_advance_cursor.return_value = {'updated': True}
mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product_name}')
result = sync_external_printing_records.run(limit=100)
self.assertEqual(result['jobs_created'], 1)
self.assertEqual(result['failed_records'], 0)
self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists())
product = basic_models.Product.objects.get(merchant=self.merchant, name=product_name)
self.assertFalse(product.image)
job = printing_models.PrintingJob.objects.get(original_id=1000101)
self.assertEqual(job.product, product)
@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_existing_non_white_product_without_image_still_records_failure_when_image_missing(
self,
mock_fetch_records,
mock_fetch_image,
mock_advance_cursor,
):
product = basic_models.Product.objects.create(
merchant=self.merchant,
category=self.category,
name='Tj1712#12号色-无图',
)
mock_fetch_records.return_value = self._build_payload(
[self._build_record(record_id=1000102, product_name=product.name)]
)
mock_advance_cursor.return_value = {'updated': True}
mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product.name}')
result = sync_external_printing_records.run(limit=100)
self.assertEqual(result['jobs_created'], 0)
self.assertEqual(result['failed_records'], 1)
self.assertEqual(result['failed_record_ids'], [1000102])
self.assertFalse(printing_models.PrintingJob.objects.filter(original_id=1000102).exists())
failure = api_models.PrintingExternalSyncFailure.objects.get(external_record_id=1000102)
self.assertEqual(failure.product_name, product.name)
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')
@@ -324,6 +404,16 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
self.assertIn("'remaining_failures': 0", output.getvalue())
self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists())
@patch('api_v1.management.commands.sync_external_printing_records.sync_external_printing_records')
def test_sync_command_supports_no_advance_cursor(self, mock_task):
mock_task.run.return_value = {'cursor_updated': False, 'advance_cursor': False}
output = StringIO()
call_command('sync_external_printing_records', '--limit', '5', '--no-advance-cursor', stdout=output)
mock_task.run.assert_called_once_with(limit=5, advance_cursor=False)
self.assertIn("'advance_cursor': False", 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')

View File

@@ -385,6 +385,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
fabric = serializers.SerializerMethodField()
curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True)
new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True)
class Meta:
model = models.PrintingJob
@@ -400,6 +402,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
'batch_advance_records',
'saleitems',
'fabric',
'curve',
'new_curve',
'created_at', 'updated_at'
]
read_only_fields = [
@@ -497,6 +501,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
fabric = serializers.SerializerMethodField()
curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True)
new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True)
class Meta:
model = models.PrintingJob
@@ -511,6 +517,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
'batch_advance_records',
'saleitems',
'fabric',
'curve',
'new_curve',
'created_at', 'updated_at'
]
read_only_fields = [

View File

@@ -77,6 +77,8 @@ class PrintingJobAPITestCase(TestCase):
customer=self.customer,
fabric='测试布料',
width='150cm',
curve='测试曲线',
new_curve='测试新曲线',
process=self.process,
)
@@ -191,6 +193,8 @@ class PrintingJobAPITestCase(TestCase):
for item in response.data['results']:
self.assertIn('customer_name', item)
self.assertEqual(item['customer_name'], self.customer.name)
self.assertEqual(item['curve'], '测试曲线')
self.assertEqual(item['new_curve'], '测试新曲线')
self.assertIn('batch_advance_records', item)
self.assertIsInstance(item['batch_advance_records'], list)
self.assertEqual(len(item['batch_advance_records']), 0)
@@ -944,6 +948,8 @@ class PrintingJobAPITestCase(TestCase):
detail = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
self.assertEqual(detail.status_code, status.HTTP_200_OK)
self.assertEqual(detail.data['external_order_id'], 'KD20410611')
self.assertEqual(detail.data['curve'], '测试曲线')
self.assertEqual(detail.data['new_curve'], '测试新曲线')
def test_filter_by_quantity_range(self):
"""测试按数量范围过滤"""