From aa97e61c891d57afa2972ed2da1e9d1e1c812fef Mon Sep 17 00:00:00 2001 From: colaftc Date: Wed, 25 Mar 2026 16:27:11 +0800 Subject: [PATCH] fix: change backfill product image --- .../backfill_external_product_images.py | 78 ++++++--- ...ackfill_external_product_images_command.py | 161 ++++++++++++++---- 2 files changed, 180 insertions(+), 59 deletions(-) diff --git a/api_v1/management/commands/backfill_external_product_images.py b/api_v1/management/commands/backfill_external_product_images.py index 7e7cf6e..94f032f 100644 --- a/api_v1/management/commands/backfill_external_product_images.py +++ b/api_v1/management/commands/backfill_external_product_images.py @@ -2,11 +2,11 @@ from django.core.management.base import BaseCommand, CommandError from django.db.models import Q from api_v1.tasks import _fetch_external_product_image, _upload_product_image -from basic_info.models import Product +from printing.models import PrintingJob class Command(BaseCommand): - help = '为 image 为空但 get_primary_image_url() 非空的 Product 通过外部 image API 回补图片' + help = '为来自外部印染订单的生产子单所关联且 image 为空的 Product 通过 external_raw.YanSe 回补图片' def add_arguments(self, parser): parser.add_argument('--merchant-id', type=int, help='仅处理指定商户的产品') @@ -18,24 +18,33 @@ class Command(BaseCommand): help='仅输出将处理的产品,不实际请求外部 API 或写库', ) + @staticmethod + def _extract_request_name(job: PrintingJob) -> str: + raw = job.external_raw or {} + if isinstance(raw, dict): + request_name = str(raw.get('YanSe') or '').strip() + if request_name: + return request_name + return str(job.external_product_name or '').strip() + def handle(self, *args, **options): - queryset = Product.objects.filter( - Q(image__isnull=True) | Q(image=''), - ).exclude( - name__isnull=True, - ).exclude( - name='', - ).select_related('merchant') + queryset = ( + PrintingJob.objects.filter( + printing_order__external_order_id__isnull=False, + ) + .exclude(printing_order__external_order_id='') + .filter(Q(product__image__isnull=True) | Q(product__image='')) + .select_related('product', 'printing_order', 'product__merchant') + .order_by('-id') + ) merchant_id = options.get('merchant_id') if merchant_id: - queryset = queryset.filter(merchant_id=merchant_id) + queryset = queryset.filter(product__merchant_id=merchant_id) product_id = options.get('product_id') if product_id: - queryset = queryset.filter(id=product_id) - - queryset = queryset.order_by('-id') + queryset = queryset.filter(product_id=product_id) limit = options.get('limit') if limit is not None: @@ -45,44 +54,60 @@ class Command(BaseCommand): success_count = 0 failed_count = 0 - skipped_count = 0 - skipped_no_primary_count = 0 + skipped_with_image_count = 0 + skipped_without_request_name_count = 0 + skipped_duplicate_product_count = 0 selected_count = 0 scanned_count = 0 dry_run = bool(options.get('dry_run')) + seen_product_ids = set() - for product in queryset.iterator(chunk_size=1000): + for job in queryset.iterator(chunk_size=1000): scanned_count += 1 + product = job.product if product.image: - skipped_count += 1 + skipped_with_image_count += 1 continue - if not product.get_primary_image_url(): - skipped_no_primary_count += 1 + if product.id in seen_product_ids: + skipped_duplicate_product_count += 1 continue + request_name = self._extract_request_name(job) + if not request_name: + skipped_without_request_name_count += 1 + continue + + seen_product_ids.add(product.id) + selected_count += 1 if dry_run: self.stdout.write( - f'[DRY-RUN] product_id={product.id} merchant_id={product.merchant_id} name={product.name}' + '[DRY-RUN] ' + f'job_id={job.id} ' + f'product_id={product.id} ' + f'merchant_id={product.merchant_id} ' + f'product_name={product.name} ' + f'request_name={request_name}' ) else: try: - image_payload = _fetch_external_product_image(product.name) + image_payload = _fetch_external_product_image(request_name) _upload_product_image(product, image_payload) except Exception as exc: failed_count += 1 self.stderr.write( self.style.ERROR( - f'补图失败: product_id={product.id} merchant_id={product.merchant_id} ' - f'name={product.name} error={exc}' + f'补图失败: job_id={job.id} product_id={product.id} merchant_id={product.merchant_id} ' + f'product_name={product.name} request_name={request_name} error={exc}' ) ) else: success_count += 1 self.stdout.write( self.style.SUCCESS( - f'补图成功: product_id={product.id} merchant_id={product.merchant_id} name={product.name}' + f'补图成功: job_id={job.id} product_id={product.id} merchant_id={product.merchant_id} ' + f'product_name={product.name} request_name={request_name}' ) ) @@ -99,7 +124,8 @@ class Command(BaseCommand): f'selected={selected_count} ' f'success={success_count} ' f'failed={failed_count} ' - f'skipped_with_image={skipped_count} ' - f'skipped_without_primary_url={skipped_no_primary_count}' + f'skipped_with_image={skipped_with_image_count} ' + f'skipped_without_request_name={skipped_without_request_name_count} ' + f'skipped_duplicate_product={skipped_duplicate_product_count}' ) ) diff --git a/api_v1/test_backfill_external_product_images_command.py b/api_v1/test_backfill_external_product_images_command.py index 23a9a9c..e139829 100644 --- a/api_v1/test_backfill_external_product_images_command.py +++ b/api_v1/test_backfill_external_product_images_command.py @@ -1,4 +1,3 @@ -import json from io import StringIO from unittest.mock import patch @@ -6,6 +5,7 @@ from django.core.management import call_command from django.test import TestCase from basic_info import models as basic_models +from printing import models as printing_models class BackfillExternalProductImagesCommandTest(TestCase): @@ -28,6 +28,14 @@ class BackfillExternalProductImagesCommandTest(TestCase): name='其他分类', product_prefix='OP', ) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='测试客户', + ) + self.other_customer = basic_models.Customer.objects.create( + merchant=self.other_merchant, + name='其他客户', + ) def _create_product(self, *, merchant, category, name, image=None, description=None): return basic_models.Product.objects.create( @@ -38,39 +46,80 @@ class BackfillExternalProductImagesCommandTest(TestCase): description=description, ) + def _create_printing_order(self, *, merchant, customer, external_order_id=None): + return printing_models.PrintingOrder.objects.create( + merchant=merchant, + customer=customer, + fabric='测试面料', + width='1.50', + external_order_id=external_order_id, + ) + + def _create_printing_job(self, *, order, product, yanse=None): + raw = {} + if yanse is not None: + raw['YanSe'] = yanse + return printing_models.PrintingJob.objects.create( + merchant=product.merchant, + printing_order=order, + product=product, + quantity=1, + unit='段', + external_raw=raw, + ) + @patch('api_v1.management.commands.backfill_external_product_images._upload_product_image') @patch('api_v1.management.commands.backfill_external_product_images._fetch_external_product_image') - def test_command_only_processes_products_with_primary_image_url( + def test_command_only_processes_external_jobs_with_yanse( self, mock_fetch_image, mock_upload_image, ): - eligible = self._create_product( + eligible_product = self._create_product( merchant=self.merchant, category=self.category, - name='有图源产品#1', - description=json.dumps({'original_file_full_path': 'https://example.com/a.jpg'}), + name='本地产品名#1', ) - self._create_product( + no_yanse_product = self._create_product( merchant=self.merchant, category=self.category, - name='无图源产品#2', - description=None, + name='本地产品名#2', ) - self._create_product( + has_image_product = self._create_product( merchant=self.merchant, category=self.category, name='已有图片产品#3', image='product_images/existing.jpg', - description=json.dumps({'original_file_full_path': 'https://example.com/existing.jpg'}), ) - self._create_product( + other_product = self._create_product( merchant=self.other_merchant, category=self.other_category, name='其他商户产品#4', - description=json.dumps({'original_file_full_path': 'https://example.com/other.jpg'}), ) + external_order = self._create_printing_order( + merchant=self.merchant, + customer=self.customer, + external_order_id='KD10001', + ) + self._create_printing_job(order=external_order, product=eligible_product, yanse='A093#蓝色-L码') + self._create_printing_job(order=external_order, product=no_yanse_product, yanse='') + self._create_printing_job(order=external_order, product=has_image_product, yanse='已有图片产品外部名') + + internal_order = self._create_printing_order( + merchant=self.merchant, + customer=self.customer, + external_order_id=None, + ) + self._create_printing_job(order=internal_order, product=no_yanse_product, yanse='内部订单不应处理') + + other_order = self._create_printing_order( + merchant=self.other_merchant, + customer=self.other_customer, + external_order_id='KD20001', + ) + self._create_printing_job(order=other_order, product=other_product, yanse='其他商户外部名') + mock_fetch_image.return_value = { 'bytes': b'fake-image', 'content_type': 'image/jpeg', @@ -85,36 +134,36 @@ class BackfillExternalProductImagesCommandTest(TestCase): stdout=stdout, ) - mock_fetch_image.assert_called_once_with(eligible.name) + mock_fetch_image.assert_called_once_with('A093#蓝色-L码') self.assertEqual(mock_upload_image.call_count, 1) self.assertIn('selected=1', stdout.getvalue()) - self.assertIn('skipped_without_primary_url=1', stdout.getvalue()) + self.assertIn('skipped_without_request_name=1', stdout.getvalue()) @patch('api_v1.management.commands.backfill_external_product_images._upload_product_image') @patch('api_v1.management.commands.backfill_external_product_images._fetch_external_product_image') - def test_limit_applies_after_primary_url_filtering( + def test_limit_applies_after_unique_products_selected( self, mock_fetch_image, mock_upload_image, ): - self._create_product( + first_product = self._create_product( merchant=self.merchant, category=self.category, - name='无图源产品#0', - description=None, + name='本地产品#1', ) - self._create_product( + second_product = self._create_product( merchant=self.merchant, category=self.category, - name='有图源产品#1', - description=json.dumps({'original_file_full_path': 'https://example.com/1.jpg'}), + name='本地产品#2', ) - eligible_two = self._create_product( + + order = self._create_printing_order( merchant=self.merchant, - category=self.category, - name='有图源产品#2', - description=json.dumps({'original_file_full_path': 'https://example.com/2.jpg'}), + customer=self.customer, + external_order_id='KD30001', ) + self._create_printing_job(order=order, product=first_product, yanse='A001#红色-S码') + self._create_printing_job(order=order, product=second_product, yanse='A001#蓝色-L码') mock_fetch_image.return_value = { 'bytes': b'fake-image', @@ -132,7 +181,7 @@ class BackfillExternalProductImagesCommandTest(TestCase): stdout=stdout, ) - mock_fetch_image.assert_called_once_with(eligible_two.name) + mock_fetch_image.assert_called_once_with('A001#蓝色-L码') self.assertEqual(mock_upload_image.call_count, 1) self.assertIn('selected=1', stdout.getvalue()) self.assertIn('success=1', stdout.getvalue()) @@ -141,16 +190,23 @@ class BackfillExternalProductImagesCommandTest(TestCase): eligible = self._create_product( merchant=self.merchant, category=self.category, - name='有图源产品#dry', - description=json.dumps({'original_file_full_path': 'https://example.com/dry.jpg'}), + name='本地产品#dry', ) - self._create_product( + duplicate = self._create_product( merchant=self.merchant, category=self.category, - name='无图源产品#dry', - description=None, + name='本地产品#duplicate', ) + order = self._create_printing_order( + merchant=self.merchant, + customer=self.customer, + external_order_id='KD40001', + ) + self._create_printing_job(order=order, product=eligible, yanse='A009#紫色-M码') + self._create_printing_job(order=order, product=duplicate, yanse='') + self._create_printing_job(order=order, product=eligible, yanse='A009#紫色-M码') + stdout = StringIO() call_command( 'backfill_external_product_images', @@ -162,5 +218,44 @@ class BackfillExternalProductImagesCommandTest(TestCase): output = stdout.getvalue() self.assertIn(eligible.name, output) - self.assertNotIn('无图源产品#dry', output) - self.assertIn('dry-run 完成', output) \ No newline at end of file + self.assertIn('request_name=A009#紫色-M码', output) + self.assertNotIn('本地产品#duplicate', output) + self.assertIn('dry-run 完成', output) + + @patch('api_v1.management.commands.backfill_external_product_images._upload_product_image') + @patch('api_v1.management.commands.backfill_external_product_images._fetch_external_product_image') + def test_same_product_is_requested_once_even_if_multiple_jobs_exist( + self, + mock_fetch_image, + mock_upload_image, + ): + product = self._create_product( + merchant=self.merchant, + category=self.category, + name='本地产品#same', + ) + order = self._create_printing_order( + merchant=self.merchant, + customer=self.customer, + external_order_id='KD50001', + ) + self._create_printing_job(order=order, product=product, yanse='A777#黑色-XL码') + self._create_printing_job(order=order, product=product, yanse='A777#黑色-XL码') + + mock_fetch_image.return_value = { + 'bytes': b'fake-image', + 'content_type': 'image/jpeg', + 'name': 'from-api.jpg', + } + + stdout = StringIO() + call_command( + 'backfill_external_product_images', + '--merchant-id', + str(self.merchant.id), + stdout=stdout, + ) + + mock_fetch_image.assert_called_once_with('A777#黑色-XL码') + self.assertEqual(mock_upload_image.call_count, 1) + self.assertIn('skipped_duplicate_product=1', stdout.getvalue()) \ No newline at end of file