forked from erp-dev/erp
feat: error_code + api doc + printing-job fields
This commit is contained in:
22
api_v1/management/commands/set_app_version.py
Normal file
22
api_v1/management/commands/set_app_version.py
Normal 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)))
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
"""测试按数量范围过滤"""
|
||||
|
||||
Reference in New Issue
Block a user