forked from erp-dev/erp
fix: change backfill product image
This commit is contained in:
@@ -2,11 +2,11 @@ from django.core.management.base import BaseCommand, CommandError
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from api_v1.tasks import _fetch_external_product_image, _upload_product_image
|
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):
|
class Command(BaseCommand):
|
||||||
help = '为 image 为空但 get_primary_image_url() 非空的 Product 通过外部 image API 回补图片'
|
help = '为来自外部印染订单的生产子单所关联且 image 为空的 Product 通过 external_raw.YanSe 回补图片'
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--merchant-id', type=int, help='仅处理指定商户的产品')
|
parser.add_argument('--merchant-id', type=int, help='仅处理指定商户的产品')
|
||||||
@@ -18,24 +18,33 @@ class Command(BaseCommand):
|
|||||||
help='仅输出将处理的产品,不实际请求外部 API 或写库',
|
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):
|
def handle(self, *args, **options):
|
||||||
queryset = Product.objects.filter(
|
queryset = (
|
||||||
Q(image__isnull=True) | Q(image=''),
|
PrintingJob.objects.filter(
|
||||||
).exclude(
|
printing_order__external_order_id__isnull=False,
|
||||||
name__isnull=True,
|
)
|
||||||
).exclude(
|
.exclude(printing_order__external_order_id='')
|
||||||
name='',
|
.filter(Q(product__image__isnull=True) | Q(product__image=''))
|
||||||
).select_related('merchant')
|
.select_related('product', 'printing_order', 'product__merchant')
|
||||||
|
.order_by('-id')
|
||||||
|
)
|
||||||
|
|
||||||
merchant_id = options.get('merchant_id')
|
merchant_id = options.get('merchant_id')
|
||||||
if 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')
|
product_id = options.get('product_id')
|
||||||
if product_id:
|
if product_id:
|
||||||
queryset = queryset.filter(id=product_id)
|
queryset = queryset.filter(product_id=product_id)
|
||||||
|
|
||||||
queryset = queryset.order_by('-id')
|
|
||||||
|
|
||||||
limit = options.get('limit')
|
limit = options.get('limit')
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
@@ -45,44 +54,60 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
success_count = 0
|
success_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
skipped_count = 0
|
skipped_with_image_count = 0
|
||||||
skipped_no_primary_count = 0
|
skipped_without_request_name_count = 0
|
||||||
|
skipped_duplicate_product_count = 0
|
||||||
selected_count = 0
|
selected_count = 0
|
||||||
scanned_count = 0
|
scanned_count = 0
|
||||||
dry_run = bool(options.get('dry_run'))
|
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
|
scanned_count += 1
|
||||||
|
product = job.product
|
||||||
if product.image:
|
if product.image:
|
||||||
skipped_count += 1
|
skipped_with_image_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not product.get_primary_image_url():
|
if product.id in seen_product_ids:
|
||||||
skipped_no_primary_count += 1
|
skipped_duplicate_product_count += 1
|
||||||
continue
|
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
|
selected_count += 1
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(
|
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:
|
else:
|
||||||
try:
|
try:
|
||||||
image_payload = _fetch_external_product_image(product.name)
|
image_payload = _fetch_external_product_image(request_name)
|
||||||
_upload_product_image(product, image_payload)
|
_upload_product_image(product, image_payload)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
self.stderr.write(
|
self.stderr.write(
|
||||||
self.style.ERROR(
|
self.style.ERROR(
|
||||||
f'补图失败: product_id={product.id} merchant_id={product.merchant_id} '
|
f'补图失败: job_id={job.id} product_id={product.id} merchant_id={product.merchant_id} '
|
||||||
f'name={product.name} error={exc}'
|
f'product_name={product.name} request_name={request_name} error={exc}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
success_count += 1
|
success_count += 1
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
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'selected={selected_count} '
|
||||||
f'success={success_count} '
|
f'success={success_count} '
|
||||||
f'failed={failed_count} '
|
f'failed={failed_count} '
|
||||||
f'skipped_with_image={skipped_count} '
|
f'skipped_with_image={skipped_with_image_count} '
|
||||||
f'skipped_without_primary_url={skipped_no_primary_count}'
|
f'skipped_without_request_name={skipped_without_request_name_count} '
|
||||||
|
f'skipped_duplicate_product={skipped_duplicate_product_count}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -6,6 +5,7 @@ from django.core.management import call_command
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
|
from printing import models as printing_models
|
||||||
|
|
||||||
|
|
||||||
class BackfillExternalProductImagesCommandTest(TestCase):
|
class BackfillExternalProductImagesCommandTest(TestCase):
|
||||||
@@ -28,6 +28,14 @@ class BackfillExternalProductImagesCommandTest(TestCase):
|
|||||||
name='其他分类',
|
name='其他分类',
|
||||||
product_prefix='OP',
|
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):
|
def _create_product(self, *, merchant, category, name, image=None, description=None):
|
||||||
return basic_models.Product.objects.create(
|
return basic_models.Product.objects.create(
|
||||||
@@ -38,39 +46,80 @@ class BackfillExternalProductImagesCommandTest(TestCase):
|
|||||||
description=description,
|
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._upload_product_image')
|
||||||
@patch('api_v1.management.commands.backfill_external_product_images._fetch_external_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,
|
self,
|
||||||
mock_fetch_image,
|
mock_fetch_image,
|
||||||
mock_upload_image,
|
mock_upload_image,
|
||||||
):
|
):
|
||||||
eligible = self._create_product(
|
eligible_product = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='有图源产品#1',
|
name='本地产品名#1',
|
||||||
description=json.dumps({'original_file_full_path': 'https://example.com/a.jpg'}),
|
|
||||||
)
|
)
|
||||||
self._create_product(
|
no_yanse_product = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='无图源产品#2',
|
name='本地产品名#2',
|
||||||
description=None,
|
|
||||||
)
|
)
|
||||||
self._create_product(
|
has_image_product = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='已有图片产品#3',
|
name='已有图片产品#3',
|
||||||
image='product_images/existing.jpg',
|
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,
|
merchant=self.other_merchant,
|
||||||
category=self.other_category,
|
category=self.other_category,
|
||||||
name='其他商户产品#4',
|
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 = {
|
mock_fetch_image.return_value = {
|
||||||
'bytes': b'fake-image',
|
'bytes': b'fake-image',
|
||||||
'content_type': 'image/jpeg',
|
'content_type': 'image/jpeg',
|
||||||
@@ -85,36 +134,36 @@ class BackfillExternalProductImagesCommandTest(TestCase):
|
|||||||
stdout=stdout,
|
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.assertEqual(mock_upload_image.call_count, 1)
|
||||||
self.assertIn('selected=1', stdout.getvalue())
|
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._upload_product_image')
|
||||||
@patch('api_v1.management.commands.backfill_external_product_images._fetch_external_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,
|
self,
|
||||||
mock_fetch_image,
|
mock_fetch_image,
|
||||||
mock_upload_image,
|
mock_upload_image,
|
||||||
):
|
):
|
||||||
self._create_product(
|
first_product = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='无图源产品#0',
|
name='本地产品#1',
|
||||||
description=None,
|
|
||||||
)
|
)
|
||||||
self._create_product(
|
second_product = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='有图源产品#1',
|
name='本地产品#2',
|
||||||
description=json.dumps({'original_file_full_path': 'https://example.com/1.jpg'}),
|
|
||||||
)
|
)
|
||||||
eligible_two = self._create_product(
|
|
||||||
|
order = self._create_printing_order(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
customer=self.customer,
|
||||||
name='有图源产品#2',
|
external_order_id='KD30001',
|
||||||
description=json.dumps({'original_file_full_path': 'https://example.com/2.jpg'}),
|
|
||||||
)
|
)
|
||||||
|
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 = {
|
mock_fetch_image.return_value = {
|
||||||
'bytes': b'fake-image',
|
'bytes': b'fake-image',
|
||||||
@@ -132,7 +181,7 @@ class BackfillExternalProductImagesCommandTest(TestCase):
|
|||||||
stdout=stdout,
|
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.assertEqual(mock_upload_image.call_count, 1)
|
||||||
self.assertIn('selected=1', stdout.getvalue())
|
self.assertIn('selected=1', stdout.getvalue())
|
||||||
self.assertIn('success=1', stdout.getvalue())
|
self.assertIn('success=1', stdout.getvalue())
|
||||||
@@ -141,16 +190,23 @@ class BackfillExternalProductImagesCommandTest(TestCase):
|
|||||||
eligible = self._create_product(
|
eligible = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='有图源产品#dry',
|
name='本地产品#dry',
|
||||||
description=json.dumps({'original_file_full_path': 'https://example.com/dry.jpg'}),
|
|
||||||
)
|
)
|
||||||
self._create_product(
|
duplicate = self._create_product(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='无图源产品#dry',
|
name='本地产品#duplicate',
|
||||||
description=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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()
|
stdout = StringIO()
|
||||||
call_command(
|
call_command(
|
||||||
'backfill_external_product_images',
|
'backfill_external_product_images',
|
||||||
@@ -162,5 +218,44 @@ class BackfillExternalProductImagesCommandTest(TestCase):
|
|||||||
|
|
||||||
output = stdout.getvalue()
|
output = stdout.getvalue()
|
||||||
self.assertIn(eligible.name, output)
|
self.assertIn(eligible.name, output)
|
||||||
self.assertNotIn('无图源产品#dry', output)
|
self.assertIn('request_name=A009#紫色-M码', output)
|
||||||
|
self.assertNotIn('本地产品#duplicate', output)
|
||||||
self.assertIn('dry-run 完成', 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())
|
||||||
Reference in New Issue
Block a user