forked from erp-dev/erp
feat: retry haobuye sync order
This commit is contained in:
@@ -1,13 +1,6 @@
|
||||
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,
|
||||
retry_external_printing_sync_failures_impl,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,100 +25,14 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
limit = options.get('limit')
|
||||
if limit is not None:
|
||||
limit = max(1, int(limit))
|
||||
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 limit is not None and 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()
|
||||
|
||||
remaining_failures = api_models.PrintingExternalSyncFailure.objects.count()
|
||||
|
||||
result = {
|
||||
'limit': limit,
|
||||
'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,
|
||||
'remaining_failures': remaining_failures,
|
||||
}
|
||||
self.stdout.write(self.style.SUCCESS(str(result)))
|
||||
try:
|
||||
result = retry_external_printing_sync_failures_impl(
|
||||
limit=limit,
|
||||
run_date_text=run_date_text,
|
||||
record_ids=record_ids,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise CommandError(f'--run-date 格式非法: {run_date_text}') from exc
|
||||
self.stdout.write(self.style.SUCCESS(str(result)))
|
||||
|
||||
118
api_v1/tasks.py
118
api_v1/tasks.py
@@ -8,7 +8,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
@@ -767,6 +767,122 @@ def _sync_external_printing_records_batch(
|
||||
}
|
||||
|
||||
|
||||
def retry_external_printing_sync_failures_impl(
|
||||
*,
|
||||
limit: int | None = None,
|
||||
run_date_text: str | None = None,
|
||||
record_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
if limit is not None:
|
||||
limit = max(1, int(limit))
|
||||
record_ids = record_ids 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:
|
||||
failures_qs = failures_qs.filter(run_date=date.fromisoformat(run_date_text))
|
||||
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 limit is not None and len(selected_failures) >= limit:
|
||||
break
|
||||
|
||||
if not selected_failures:
|
||||
return {'limit': limit, 'retried_records': 0, 'message': '没有可重试的失败记录'}
|
||||
|
||||
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()
|
||||
|
||||
remaining_failures = api_models.PrintingExternalSyncFailure.objects.count()
|
||||
|
||||
return {
|
||||
'limit': limit,
|
||||
'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,
|
||||
'remaining_failures': remaining_failures,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def retry_external_printing_sync_failures(
|
||||
self,
|
||||
limit: int = 100,
|
||||
run_date_text: str | None = None,
|
||||
record_ids: list[int] | None = None,
|
||||
):
|
||||
payload = retry_external_printing_sync_failures_impl(
|
||||
limit=max(1, int(limit or 100)),
|
||||
run_date_text=run_date_text,
|
||||
record_ids=record_ids or [],
|
||||
)
|
||||
payload['task_id'] = self.request.id
|
||||
logger.info('重试外部印染同步失败记录完成: %s', payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _run_fetch(page: int, page_size: int):
|
||||
return asyncio.run(fetch_products_from_mingdaoyun(page=page, page_size=page_size))
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from .views.users import CreateUserWithProfileView
|
||||
from .views.mingdaoyun import MDYPlateOrderStagingViewSet
|
||||
from .views.shipment import (
|
||||
SalesItemDetailView,
|
||||
SalesItemRebuildView,
|
||||
SalesItemByCustomerView,
|
||||
SalesItemByPrintingOrderView,
|
||||
SalesItemCreateView,
|
||||
@@ -364,6 +365,11 @@ urlpatterns = [
|
||||
SalesItemDetailView.as_view(),
|
||||
name="sales_item_detail",
|
||||
),
|
||||
path(
|
||||
"shipment/sales-items/<int:pk>/rebuild/",
|
||||
SalesItemRebuildView.as_view(),
|
||||
name="sales_item_rebuild",
|
||||
),
|
||||
path(
|
||||
"shipment/sales-items/", SalesItemCreateView.as_view(), name="sales_item_create"
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ Shipment API 模块
|
||||
"""
|
||||
from .views import (
|
||||
SalesItemDetailView,
|
||||
SalesItemRebuildView,
|
||||
SalesItemByCustomerView,
|
||||
SalesItemByPrintingOrderView,
|
||||
SalesItemCreateView,
|
||||
@@ -22,6 +23,7 @@ from .views import (
|
||||
|
||||
__all__ = [
|
||||
'SalesItemDetailView',
|
||||
'SalesItemRebuildView',
|
||||
'SalesItemByCustomerView',
|
||||
'SalesItemByPrintingOrderView',
|
||||
'SalesItemCreateView',
|
||||
|
||||
@@ -394,6 +394,7 @@ class SalesItemSerializer(serializers.Serializer):
|
||||
shipment_date = serializers.DateField(
|
||||
source="shipment.shipment_date", read_only=True, allow_null=True
|
||||
)
|
||||
merge_remark = serializers.JSONField(read_only=True, allow_null=True)
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
created_by_id = serializers.IntegerField(
|
||||
source="created_by.id", read_only=True, allow_null=True
|
||||
@@ -706,6 +707,60 @@ class SalesItemCreateSerializer(serializers.Serializer):
|
||||
default="",
|
||||
help_text="货位(可选)",
|
||||
)
|
||||
merge_remark = serializers.JSONField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None,
|
||||
help_text="合卷备注JSON(可选)",
|
||||
)
|
||||
|
||||
_MERGE_REMARK_REQUIRED_KEYS = {
|
||||
"merge_type": str,
|
||||
"jobs": list,
|
||||
"main_job": int,
|
||||
"quantity": str,
|
||||
"unit": str,
|
||||
"job_count": int,
|
||||
}
|
||||
|
||||
def validate_merge_remark(self, value):
|
||||
"""严格校验 merge_remark 的结构"""
|
||||
if value is None:
|
||||
return value
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("merge_remark 必须是一个 JSON 对象")
|
||||
|
||||
missing = set(self._MERGE_REMARK_REQUIRED_KEYS) - set(value.keys())
|
||||
if missing:
|
||||
raise serializers.ValidationError(f"缺少必填字段: {sorted(missing)}")
|
||||
|
||||
extra = set(value.keys()) - set(self._MERGE_REMARK_REQUIRED_KEYS)
|
||||
if extra:
|
||||
raise serializers.ValidationError(f"包含未知字段: {sorted(extra)}")
|
||||
|
||||
for key, expected_type in self._MERGE_REMARK_REQUIRED_KEYS.items():
|
||||
if not isinstance(value[key], expected_type):
|
||||
raise serializers.ValidationError(
|
||||
f"字段 '{key}' 类型错误,期望 {expected_type.__name__},"
|
||||
f"实际 {type(value[key]).__name__}"
|
||||
)
|
||||
|
||||
# jobs 列表内的元素必须都是整数
|
||||
if not all(isinstance(j, int) for j in value["jobs"]):
|
||||
raise serializers.ValidationError("jobs 列表中的元素必须为整数")
|
||||
|
||||
if len(value["jobs"]) < 2:
|
||||
raise serializers.ValidationError("jobs 列表至少需要包含 2 个子单ID")
|
||||
|
||||
if value["main_job"] not in value["jobs"]:
|
||||
raise serializers.ValidationError("main_job 必须是 jobs 列表中的一个成员")
|
||||
|
||||
if value["job_count"] != len(value["jobs"]):
|
||||
raise serializers.ValidationError(
|
||||
f"job_count ({value['job_count']}) 与 jobs 长度 ({len(value['jobs'])}) 不一致"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def validate_unit(self, value):
|
||||
"""验证单位值是否在允许范围内"""
|
||||
@@ -734,6 +789,7 @@ class SalesItemCreateSerializer(serializers.Serializer):
|
||||
customer_id=validated_data.get("customer_id"),
|
||||
remark=validated_data.get("remark", ""),
|
||||
position=validated_data.get("position", ""),
|
||||
merge_remark=validated_data.get("merge_remark"),
|
||||
)
|
||||
|
||||
|
||||
@@ -766,3 +822,21 @@ class SalesItemUpdateSerializer(serializers.Serializer):
|
||||
if not attrs:
|
||||
raise serializers.ValidationError("至少提供一个可修改字段")
|
||||
return attrs
|
||||
|
||||
|
||||
class SalesItemRebuildSerializer(serializers.Serializer):
|
||||
"""
|
||||
销售品重建序列化器。
|
||||
"""
|
||||
|
||||
new_printing_job_id = serializers.IntegerField(
|
||||
required=True,
|
||||
min_value=1,
|
||||
help_text="新的生产任务ID(必填)",
|
||||
)
|
||||
quantity = serializers.CharField(
|
||||
max_length=20,
|
||||
required=False,
|
||||
allow_blank=False,
|
||||
help_text="新的数量(可选,未传则沿用原销售品数量)",
|
||||
)
|
||||
|
||||
@@ -745,6 +745,18 @@ class SalesItemDetailAPITestCase(APITestCase):
|
||||
mobile="13800138032",
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.same_merchant_other_user = User.objects.create_user(
|
||||
username="sales_item_detail_same_merchant_user",
|
||||
password="testpass123",
|
||||
email="sales_item_detail_same_merchant@example.com",
|
||||
)
|
||||
self.same_merchant_other_employee = basic_models.Employee.objects.create(
|
||||
sys_user=self.same_merchant_other_user,
|
||||
merchant=self.merchant,
|
||||
name="同商户其它员工",
|
||||
mobile="13800138033",
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
@@ -783,6 +795,47 @@ class SalesItemDetailAPITestCase(APITestCase):
|
||||
unit="米",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.new_printing_job = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.merchant,
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=30,
|
||||
unit="米",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.other_customer = basic_models.Customer.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name="其它详情测试客户",
|
||||
mobile="13900139034",
|
||||
area="苏州",
|
||||
)
|
||||
self.other_category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name="其它详情分类",
|
||||
)
|
||||
self.other_product = basic_models.Product.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
category=self.other_category,
|
||||
name="其它详情产品",
|
||||
human_id="DETAIL002",
|
||||
)
|
||||
self.other_printing_order = printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
customer=self.other_customer,
|
||||
fabric="其它详情面料",
|
||||
width="160cm",
|
||||
process=self.process,
|
||||
created_by=self.other_user,
|
||||
external_order_id="EXT-DETAIL-002",
|
||||
)
|
||||
self.foreign_printing_job = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
printing_order=self.other_printing_order,
|
||||
product=self.other_product,
|
||||
quantity=40,
|
||||
unit="米",
|
||||
created_by=self.other_user,
|
||||
)
|
||||
self.sales_item = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="详情销售品",
|
||||
@@ -943,6 +996,118 @@ class SalesItemDetailAPITestCase(APITestCase):
|
||||
self.sales_item.refresh_from_db()
|
||||
self.assertIsNone(self.sales_item.delete_at)
|
||||
|
||||
def test_rebuild_sales_item_success_with_original_quantity(self):
|
||||
resp = self.client.post(
|
||||
f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/",
|
||||
{
|
||||
"new_printing_job_id": self.new_printing_job.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["detail"], "销售品已重建")
|
||||
self.assertEqual(data["old_sales_item_id"], self.sales_item.id)
|
||||
self.assertNotEqual(data["new_sales_item_id"], self.sales_item.id)
|
||||
|
||||
self.sales_item.refresh_from_db()
|
||||
self.assertIsNotNone(self.sales_item.delete_at)
|
||||
self.assertEqual(self.sales_item.delete_by, self.user)
|
||||
|
||||
new_sales_item = shipment_models.SalesItem.objects.get(id=data["new_sales_item_id"])
|
||||
self.assertEqual(new_sales_item.created_by, self.user)
|
||||
self.assertEqual(new_sales_item.printing_job_id, self.new_printing_job.id)
|
||||
self.assertEqual(new_sales_item.quantity, Decimal("88.00"))
|
||||
self.assertEqual(new_sales_item.name, self.sales_item.name)
|
||||
self.assertEqual(new_sales_item.unit, self.sales_item.unit)
|
||||
self.assertEqual(new_sales_item.customer_id, self.sales_item.customer_id)
|
||||
|
||||
rebuild_record = shipment_models.SalesItemRebuildRecord.objects.get(
|
||||
id=data["rebuild_record_id"]
|
||||
)
|
||||
self.assertEqual(rebuild_record.old_sales_item_id, self.sales_item.id)
|
||||
self.assertEqual(rebuild_record.new_sales_item_id, new_sales_item.id)
|
||||
self.assertEqual(rebuild_record.operator_id, self.user.id)
|
||||
self.assertEqual(rebuild_record.old_printing_job_id, self.printing_job.id)
|
||||
self.assertEqual(rebuild_record.new_printing_job_id, self.new_printing_job.id)
|
||||
self.assertEqual(rebuild_record.old_quantity, Decimal("88.00"))
|
||||
self.assertEqual(rebuild_record.new_quantity, Decimal("88.00"))
|
||||
|
||||
def test_rebuild_sales_item_success_with_override_quantity(self):
|
||||
resp = self.client.post(
|
||||
f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/",
|
||||
{
|
||||
"new_printing_job_id": self.new_printing_job.id,
|
||||
"quantity": "66.50",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
new_sales_item = shipment_models.SalesItem.objects.get(
|
||||
id=resp.json()["new_sales_item_id"]
|
||||
)
|
||||
self.assertEqual(new_sales_item.quantity, Decimal("66.50"))
|
||||
|
||||
def test_rebuild_sales_item_not_found(self):
|
||||
resp = self.client.post(
|
||||
"/api/v1/shipment/sales-items/999999/rebuild/",
|
||||
{
|
||||
"new_printing_job_id": self.new_printing_job.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_rebuild_sales_item_creator_mismatch_returns_403(self):
|
||||
self.client.force_authenticate(user=self.same_merchant_other_user)
|
||||
|
||||
resp = self.client.post(
|
||||
f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/",
|
||||
{
|
||||
"new_printing_job_id": self.new_printing_job.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(resp.json()["detail"], "只有销售品创建者才能执行重建")
|
||||
|
||||
def test_rebuild_sales_item_rejects_invalid_new_printing_job(self):
|
||||
resp = self.client.post(
|
||||
f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/",
|
||||
{
|
||||
"new_printing_job_id": self.foreign_printing_job.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("不存在或不属于当前商户", resp.json()["detail"])
|
||||
|
||||
def test_rebuild_sales_item_rejects_when_already_bound_to_shipment(self):
|
||||
shipment = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
shipment_date="2026-04-03",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.sales_item.shipment = shipment
|
||||
self.sales_item.save(update_fields=["shipment", "updated_at"])
|
||||
|
||||
resp = self.client.post(
|
||||
f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/",
|
||||
{
|
||||
"new_printing_job_id": self.new_printing_job.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(resp.json()["detail"], "已关联出货单的销售品不允许删除")
|
||||
|
||||
|
||||
class ShipmentCreateAPITestCase(TestCase):
|
||||
"""测试创建出货单 API"""
|
||||
|
||||
@@ -14,6 +14,7 @@ from shipment.models import Shipment, ShipmentDelivery
|
||||
|
||||
from .serializers import (
|
||||
SalesItemDetailSerializer,
|
||||
SalesItemRebuildSerializer,
|
||||
SalesItemSerializer,
|
||||
SalesItemUpdateSerializer,
|
||||
ShipmentStatusUpdateSerializer,
|
||||
@@ -984,6 +985,7 @@ class SalesItemDetailView(GenericAPIView):
|
||||
serializer_class = SalesItemDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
from shipment.models import SalesItem
|
||||
from shipment.services import get_active_sales_items_queryset
|
||||
|
||||
qs = (
|
||||
@@ -1079,3 +1081,81 @@ class SalesItemDetailView(GenericAPIView):
|
||||
except ValueError as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SalesItemRebuildView(GenericAPIView):
|
||||
"""
|
||||
销售品重建。
|
||||
|
||||
POST /api/v1/shipment/sales-items/<id>/rebuild/
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
from shipment.services import get_active_sales_items_queryset
|
||||
|
||||
qs = (
|
||||
get_active_sales_items_queryset()
|
||||
.select_related("shipment", "created_by")
|
||||
.order_by("id")
|
||||
)
|
||||
|
||||
user = self.request.user
|
||||
if getattr(user, "is_superuser", False):
|
||||
return qs
|
||||
|
||||
emp = getattr(user, "employee", None)
|
||||
merchant = getattr(emp, "merchant", None) if emp else None
|
||||
if not merchant:
|
||||
return SalesItem.objects.none()
|
||||
return qs.filter(merchant=merchant)
|
||||
|
||||
def post(self, request, pk: int):
|
||||
sales_item = self.get_queryset().filter(id=pk).first()
|
||||
if sales_item is None:
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if sales_item.created_by_id != request.user.id:
|
||||
return Response(
|
||||
{"detail": "只有销售品创建者才能执行重建"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = SalesItemRebuildSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from shipment.models import SalesItemRebuildRecord
|
||||
from shipment.services import rebuild_sales_item
|
||||
|
||||
try:
|
||||
new_sales_item = rebuild_sales_item(
|
||||
sales_item,
|
||||
new_printing_job_id=serializer.validated_data["new_printing_job_id"],
|
||||
operator=request.user,
|
||||
quantity=serializer.validated_data.get("quantity"),
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
rebuild_record = SalesItemRebuildRecord.objects.filter(
|
||||
new_sales_item_id=new_sales_item.id
|
||||
).order_by("-id").first()
|
||||
response_serializer = SalesItemDetailSerializer(
|
||||
new_sales_item,
|
||||
context={
|
||||
**_build_sales_item_serializer_context([new_sales_item]),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"detail": "销售品已重建",
|
||||
"old_sales_item_id": sales_item.id,
|
||||
"new_sales_item_id": new_sales_item.id,
|
||||
"rebuild_record_id": rebuild_record.id if rebuild_record else None,
|
||||
"data": response_serializer.data,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SalesItem 修改与删除 API 文档
|
||||
|
||||
本文档说明 `SalesItem` 销售品的修改与软删除接口。
|
||||
本文档说明 `SalesItem` 销售品的修改、重建与软删除接口。
|
||||
|
||||
## 修改接口
|
||||
|
||||
@@ -91,6 +91,105 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 重建接口
|
||||
|
||||
### 接口信息
|
||||
|
||||
- URL: `/api/v1/shipment/sales-items/{id}/rebuild/`
|
||||
- Method: `POST`
|
||||
- 认证: 需要登录(JWT Token)
|
||||
|
||||
说明:
|
||||
|
||||
- 该接口本质上等同于“软删除旧销售品 + 基于新的 `printing_job` 重建一个新销售品”
|
||||
- 只有旧销售品的 `created_by` 本人才能执行这个操作
|
||||
- 新销售品会保留旧销售品的 `created_by`
|
||||
- 旧销售品若已关联出货单,则不允许重建
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"new_printing_job_id": 456,
|
||||
"quantity": "66.50"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `new_printing_job_id`: 必填,新的生产任务 ID
|
||||
- `quantity`: 可选,新的数量;未传时沿用旧销售品数量
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "销售品已重建",
|
||||
"old_sales_item_id": 15,
|
||||
"new_sales_item_id": 18,
|
||||
"rebuild_record_id": 3,
|
||||
"data": {
|
||||
"id": 18,
|
||||
"name": "测试销售品",
|
||||
"quantity": "66.50",
|
||||
"unit": 1,
|
||||
"unit_display": "米",
|
||||
"position": "A3-02",
|
||||
"remark": "补充备注",
|
||||
"printing_job_id": 456,
|
||||
"printing_order_id": 80,
|
||||
"external_order_id": "EXT-2026-010",
|
||||
"customer_id": 8,
|
||||
"customer_name": "客户A",
|
||||
"shipment_id": null,
|
||||
"shipment_date": null,
|
||||
"created_at": "2026-04-06T16:00:00+08:00",
|
||||
"created_by_id": 3,
|
||||
"created_by_name": "测试员工",
|
||||
"product_image_url": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 重建审计记录
|
||||
|
||||
每次成功重建时,会新增一条 `SalesItemRebuildRecord`,记录:
|
||||
|
||||
- `old_sales_item`
|
||||
- `new_sales_item`
|
||||
- `operator`
|
||||
- `rebuilt_at`
|
||||
- `old_printing_job_id`
|
||||
- `new_printing_job_id`
|
||||
- `old_quantity`
|
||||
- `new_quantity`
|
||||
|
||||
### 错误响应示例
|
||||
|
||||
#### 1. 不是原创建者
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "只有销售品创建者才能执行重建"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 旧销售品已关联出货单
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "已关联出货单的销售品不允许删除"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 新生产任务无效
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "生产任务 999 不存在或不属于当前商户"
|
||||
}
|
||||
```
|
||||
|
||||
## 删除接口
|
||||
|
||||
### 接口信息
|
||||
|
||||
222
docs/sales_item_merge_remark_api.md
Normal file
222
docs/sales_item_merge_remark_api.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 销售品合卷备注(merge_remark)API 变更说明
|
||||
|
||||
> 变更日期:2026-04-06
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`SalesItem`(销售品)模型新增可空 JSON 字段 `merge_remark`,用于记录该销售品由多个生产子单合卷产生时的合卷信息。
|
||||
|
||||
本次变更涉及:
|
||||
- **创建销售品**(POST):新增可选入参 `merge_remark`,传入时进行结构校验
|
||||
- **所有返回销售品对象的接口**:响应体中新增 `merge_remark` 字段
|
||||
- **更新销售品**(PATCH):**不支持**传入 `merge_remark`,不受影响
|
||||
|
||||
---
|
||||
|
||||
## 一、受影响的 API
|
||||
|
||||
### 1. 创建销售品(新增入参)
|
||||
|
||||
```
|
||||
POST /api/v1/shipment/sales-items/
|
||||
```
|
||||
|
||||
请求体新增可选字段 `merge_remark`,不传或传 `null` 均视为无合卷信息。
|
||||
|
||||
#### 完整请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| printing_job_id | integer | 是 | 生产任务ID |
|
||||
| name | string | 是 | 销售品名称 |
|
||||
| quantity | string | 是 | 数量(支持小数) |
|
||||
| unit | integer | 是 | 单位:1=米, 2=件, 3=码, 4=个 |
|
||||
| customer_id | integer | 否 | 客户ID |
|
||||
| remark | string | 否 | 备注 |
|
||||
| position | string | 否 | 货位 |
|
||||
| **merge_remark** | object \| null | **否** | **合卷备注(见下方结构说明)** |
|
||||
|
||||
#### merge_remark 结构
|
||||
|
||||
传入时必须严格符合以下结构,字段不可多、不可少:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| merge_type | string | 合卷类型标识,固定传 `"combined_jobs"` |
|
||||
| jobs | integer[] | 参与本次合卷的所有子单ID(printing_job_id)列表,至少 2 个元素 |
|
||||
| main_job | integer | 主子单ID,**必须是 jobs 列表中的成员** |
|
||||
| quantity | string | 本次合卷销售品的最终数量,字符串形式 |
|
||||
| unit | string | 数量单位,如 `"meter"` |
|
||||
| job_count | integer | 参与合卷的子单数量,**必须等于 jobs 列表的长度** |
|
||||
|
||||
#### 请求示例(含 merge_remark)
|
||||
|
||||
```json
|
||||
{
|
||||
"printing_job_id": 9777,
|
||||
"name": "某款合卷布料",
|
||||
"quantity": "647",
|
||||
"unit": 1,
|
||||
"merge_remark": {
|
||||
"merge_type": "combined_jobs",
|
||||
"jobs": [9777, 9776],
|
||||
"main_job": 9777,
|
||||
"quantity": "647",
|
||||
"unit": "meter",
|
||||
"job_count": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例(201 Created)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 101,
|
||||
"name": "某款合卷布料",
|
||||
"quantity": "647.00",
|
||||
"unit": 1,
|
||||
"unit_display": "米",
|
||||
"position": "",
|
||||
"remark": "",
|
||||
"printing_job_id": 9777,
|
||||
"printing_order_id": 55,
|
||||
"external_order_id": null,
|
||||
"customer_id": 12,
|
||||
"customer_name": "某客户",
|
||||
"shipment_id": null,
|
||||
"shipment_date": null,
|
||||
"merge_remark": {
|
||||
"merge_type": "combined_jobs",
|
||||
"jobs": [9777, 9776],
|
||||
"main_job": 9777,
|
||||
"quantity": "647",
|
||||
"unit": "meter",
|
||||
"job_count": 2
|
||||
},
|
||||
"created_at": "2026-04-06T10:00:00Z",
|
||||
"created_by_id": 1,
|
||||
"created_by_name": "张三"
|
||||
}
|
||||
```
|
||||
|
||||
#### merge_remark 校验错误响应(400 Bad Request)
|
||||
|
||||
**缺少必填字段:**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["缺少必填字段: ['job_count', 'main_job']"]
|
||||
}
|
||||
```
|
||||
|
||||
**包含未知字段:**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["包含未知字段: ['extra_field']"]
|
||||
}
|
||||
```
|
||||
|
||||
**字段类型错误(如 main_job 传了字符串):**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["字段 'main_job' 类型错误,期望 int,实际 str"]
|
||||
}
|
||||
```
|
||||
|
||||
**jobs 列表元素不是整数:**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["jobs 列表中的元素必须为整数"]
|
||||
}
|
||||
```
|
||||
|
||||
**jobs 少于 2 个元素:**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["jobs 列表至少需要包含 2 个子单ID"]
|
||||
}
|
||||
```
|
||||
|
||||
**main_job 不在 jobs 中:**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["main_job 必须是 jobs 列表中的一个成员"]
|
||||
}
|
||||
```
|
||||
|
||||
**job_count 与 jobs 长度不一致:**
|
||||
```json
|
||||
{
|
||||
"merge_remark": ["job_count (3) 与 jobs 长度 (2) 不一致"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取销售品详情(新增返回字段)
|
||||
|
||||
```
|
||||
GET /api/v1/shipment/sales-items/<id>/
|
||||
```
|
||||
|
||||
响应体新增 `merge_remark` 字段。无合卷信息时值为 `null`。
|
||||
|
||||
---
|
||||
|
||||
### 3. 按客户查询销售品列表(新增返回字段)
|
||||
|
||||
```
|
||||
GET /api/v1/shipment/sales-items/by-customer/<customer_id>/
|
||||
```
|
||||
|
||||
列表中每条销售品对象新增 `merge_remark` 字段。
|
||||
|
||||
---
|
||||
|
||||
### 4. 按生产订单查询销售品列表(新增返回字段)
|
||||
|
||||
```
|
||||
GET /api/v1/shipment/sales-items/by-printing-order/<printing_order_id>/
|
||||
```
|
||||
|
||||
列表中每条销售品对象新增 `merge_remark` 字段。
|
||||
|
||||
---
|
||||
|
||||
### 5. 出货单详情中的嵌套销售品列表(新增返回字段)
|
||||
|
||||
```
|
||||
GET /api/v1/shipment/shipments/<id>/
|
||||
```
|
||||
|
||||
响应体 `sales_items` 数组中每条销售品对象新增 `merge_remark` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 二、不受影响的 API
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `PATCH /api/v1/shipment/sales-items/<id>/` | 更新销售品,**不接受** `merge_remark` 参数,传入会被忽略 |
|
||||
| `POST /api/v1/shipment/sales-items/<id>/rebuild/` | 重建销售品,不涉及 `merge_remark` |
|
||||
| `POST /api/v1/shipment/shipments/` | 创建出货单,不涉及 `merge_remark` |
|
||||
|
||||
---
|
||||
|
||||
## 三、merge_remark 为 null 的情况
|
||||
|
||||
普通销售品(非合卷)创建时不传 `merge_remark`,返回值中该字段为 `null`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 102,
|
||||
"name": "普通布料",
|
||||
"quantity": "200.00",
|
||||
...
|
||||
"merge_remark": null
|
||||
}
|
||||
```
|
||||
|
||||
前端展示时直接判断 `merge_remark !== null` 即可区分是否为合卷销售品。
|
||||
@@ -602,6 +602,13 @@ CELERY_BEAT_SCHEDULE = {
|
||||
'dry_run': False,
|
||||
},
|
||||
},
|
||||
'retry_external_printing_sync_failures_every_120_minutes': {
|
||||
'task': 'api_v1.tasks.retry_external_printing_sync_failures',
|
||||
'schedule': crontab(minute='0', hour='*/2'),
|
||||
'kwargs': {
|
||||
'limit': 100,
|
||||
},
|
||||
},
|
||||
# 明道云开版:同步到“暂存表”
|
||||
# - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次)
|
||||
# - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS
|
||||
|
||||
@@ -3,6 +3,7 @@ from .models import (
|
||||
ExternalFinishedProduct,
|
||||
SalesItem,
|
||||
SalesItemChangeRecord,
|
||||
SalesItemRebuildRecord,
|
||||
Shipment,
|
||||
ShipmentDelivery,
|
||||
)
|
||||
@@ -128,6 +129,38 @@ class SalesItemChangeRecordAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
@admin.register(SalesItemRebuildRecord)
|
||||
class SalesItemRebuildRecordAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"old_sales_item",
|
||||
"new_sales_item",
|
||||
"operator",
|
||||
"old_printing_job_id",
|
||||
"new_printing_job_id",
|
||||
"rebuilt_at",
|
||||
]
|
||||
list_filter = ["rebuilt_at", "created_at"]
|
||||
search_fields = [
|
||||
"old_sales_item__name",
|
||||
"new_sales_item__name",
|
||||
"operator__username",
|
||||
"operator__employee__name",
|
||||
]
|
||||
readonly_fields = [
|
||||
"old_sales_item",
|
||||
"new_sales_item",
|
||||
"operator",
|
||||
"rebuilt_at",
|
||||
"old_printing_job_id",
|
||||
"new_printing_job_id",
|
||||
"old_quantity",
|
||||
"new_quantity",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(ShipmentDelivery)
|
||||
class ShipmentDeliveryAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
|
||||
74
shipment/migrations/0019_salesitemrebuildrecord.py
Normal file
74
shipment/migrations/0019_salesitemrebuildrecord.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shipment", "0018_salesitem_soft_delete_fields"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SalesItemRebuildRecord",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
|
||||
("rebuilt_at", models.DateTimeField(verbose_name="重建时间")),
|
||||
("old_printing_job_id", models.PositiveIntegerField(blank=True, null=True, verbose_name="旧生产任务ID")),
|
||||
("new_printing_job_id", models.PositiveIntegerField(verbose_name="新生产任务ID")),
|
||||
("old_quantity", models.DecimalField(decimal_places=2, max_digits=12, verbose_name="旧数量")),
|
||||
("new_quantity", models.DecimalField(decimal_places=2, max_digits=12, verbose_name="新数量")),
|
||||
(
|
||||
"new_sales_item",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rebuild_target_records",
|
||||
to="shipment.salesitem",
|
||||
verbose_name="新销售品",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_sales_item",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rebuild_source_records",
|
||||
to="shipment.salesitem",
|
||||
verbose_name="旧销售品",
|
||||
),
|
||||
),
|
||||
(
|
||||
"operator",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sales_item_rebuild_records",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="操作者",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "销售品重建记录",
|
||||
"verbose_name_plural": "销售品重建记录",
|
||||
"db_table": "sales_item_rebuild_record",
|
||||
"ordering": ["-rebuilt_at", "-id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
shipment/migrations/0020_salesitem_merge_remark.py
Normal file
18
shipment/migrations/0020_salesitem_merge_remark.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-06 10:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shipment', '0019_salesitemrebuildrecord'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesitem',
|
||||
name='merge_remark',
|
||||
field=models.JSONField(blank=True, help_text='合卷信息JSON,包含 merge_type, jobs, main_job, quantity, unit, job_count', null=True, verbose_name='合卷备注'),
|
||||
),
|
||||
]
|
||||
@@ -317,6 +317,13 @@ class SalesItem(ModelBase):
|
||||
related_name='deleted_sales_items',
|
||||
verbose_name='删除人',
|
||||
)
|
||||
|
||||
merge_remark = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='合卷备注',
|
||||
help_text='合卷信息JSON,包含 merge_type, jobs, main_job, quantity, unit, job_count',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'sales_item'
|
||||
@@ -409,6 +416,76 @@ class SalesItemChangeRecord(ModelBase):
|
||||
return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}"
|
||||
|
||||
|
||||
class SalesItemRebuildRecord(ModelBase):
|
||||
"""
|
||||
销售品重建记录。
|
||||
|
||||
用于审计“软删除旧销售品并基于新 printing_job 重建销售品”的动作。
|
||||
"""
|
||||
|
||||
old_sales_item = models.ForeignKey(
|
||||
SalesItem,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rebuild_source_records",
|
||||
verbose_name="旧销售品",
|
||||
)
|
||||
|
||||
new_sales_item = models.ForeignKey(
|
||||
SalesItem,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rebuild_target_records",
|
||||
verbose_name="新销售品",
|
||||
)
|
||||
|
||||
operator = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="sales_item_rebuild_records",
|
||||
verbose_name="操作者",
|
||||
)
|
||||
|
||||
rebuilt_at = models.DateTimeField(
|
||||
verbose_name="重建时间",
|
||||
)
|
||||
|
||||
old_printing_job_id = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="旧生产任务ID",
|
||||
)
|
||||
|
||||
new_printing_job_id = models.PositiveIntegerField(
|
||||
verbose_name="新生产任务ID",
|
||||
)
|
||||
|
||||
old_quantity = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
verbose_name="旧数量",
|
||||
)
|
||||
|
||||
new_quantity = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
verbose_name="新数量",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "sales_item_rebuild_record"
|
||||
verbose_name = "销售品重建记录"
|
||||
verbose_name_plural = "销售品重建记录"
|
||||
ordering = ["-rebuilt_at", "-id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"SalesItemRebuildRecord #{self.id} - old={self.old_sales_item_id}, new={self.new_sales_item_id}"
|
||||
|
||||
|
||||
class ShipmentDelivery(ModelBase):
|
||||
"""
|
||||
送货单
|
||||
|
||||
@@ -16,6 +16,7 @@ from shipment.models import (
|
||||
ExternalFinishedProduct,
|
||||
SalesItem,
|
||||
SalesItemChangeRecord,
|
||||
SalesItemRebuildRecord,
|
||||
Shipment,
|
||||
ShipmentDelivery,
|
||||
ShipmentDeliveryStatus,
|
||||
@@ -50,6 +51,25 @@ def ensure_sales_item_can_be_soft_deleted(sales_item: SalesItem) -> None:
|
||||
raise ValueError("已关联出货单的销售品不允许删除")
|
||||
|
||||
|
||||
def _get_printing_job_for_sales_item(*, printing_job_id: int, merchant):
|
||||
from printing.models import PrintingJob
|
||||
|
||||
try:
|
||||
return PrintingJob.objects.select_related("printing_order").get(
|
||||
id=printing_job_id,
|
||||
merchant=merchant,
|
||||
)
|
||||
except PrintingJob.DoesNotExist:
|
||||
raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户")
|
||||
|
||||
|
||||
def _normalize_sales_item_quantity(quantity: str | Decimal) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(quantity))
|
||||
except (InvalidOperation, ValueError, TypeError) as e:
|
||||
raise ValueError(f"数量 {quantity} 格式无效: {e}")
|
||||
|
||||
|
||||
def _resolve_delivery_shipments(
|
||||
*,
|
||||
shipment_ids: list[int],
|
||||
@@ -302,6 +322,64 @@ def delete_sales_item(
|
||||
return sales_item
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def rebuild_sales_item(
|
||||
sales_item: SalesItem,
|
||||
*,
|
||||
new_printing_job_id: int,
|
||||
operator,
|
||||
quantity: str | Decimal | None = None,
|
||||
) -> SalesItem:
|
||||
"""
|
||||
软删除旧销售品,并基于新 printing_job 重建一个新销售品。
|
||||
"""
|
||||
ensure_sales_item_can_be_soft_deleted(sales_item)
|
||||
|
||||
merchant = sales_item.merchant
|
||||
printing_job = _get_printing_job_for_sales_item(
|
||||
printing_job_id=new_printing_job_id,
|
||||
merchant=merchant,
|
||||
)
|
||||
|
||||
new_quantity = (
|
||||
_normalize_sales_item_quantity(quantity)
|
||||
if quantity is not None
|
||||
else sales_item.quantity
|
||||
)
|
||||
|
||||
deleted_sales_item = delete_sales_item(sales_item, deleted_by=operator)
|
||||
|
||||
customer_id = sales_item.customer_id
|
||||
if customer_id is None and printing_job.printing_order and printing_job.printing_order.customer:
|
||||
customer_id = printing_job.printing_order.customer_id
|
||||
|
||||
new_sales_item = SalesItem.objects.create(
|
||||
shipment=None,
|
||||
merchant=merchant,
|
||||
name=sales_item.name,
|
||||
quantity=new_quantity,
|
||||
unit=sales_item.unit,
|
||||
created_by=sales_item.created_by,
|
||||
printing_job_id=printing_job.id,
|
||||
customer_id=customer_id,
|
||||
remark=sales_item.remark,
|
||||
position=sales_item.position,
|
||||
)
|
||||
|
||||
SalesItemRebuildRecord.objects.create(
|
||||
old_sales_item=deleted_sales_item,
|
||||
new_sales_item=new_sales_item,
|
||||
operator=operator,
|
||||
rebuilt_at=timezone.now(),
|
||||
old_printing_job_id=deleted_sales_item.printing_job_id,
|
||||
new_printing_job_id=printing_job.id,
|
||||
old_quantity=deleted_sales_item.quantity,
|
||||
new_quantity=new_sales_item.quantity,
|
||||
)
|
||||
|
||||
return new_sales_item
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_shipment_delivery(
|
||||
*,
|
||||
@@ -841,6 +919,7 @@ def create_sales_item(
|
||||
customer_id: int | None = None,
|
||||
remark: str = "",
|
||||
position: str = "",
|
||||
merge_remark: dict | None = None,
|
||||
) -> SalesItem:
|
||||
"""
|
||||
手动创建销售品
|
||||
@@ -861,8 +940,6 @@ def create_sales_item(
|
||||
Raises:
|
||||
ValueError: 如果生产任务不存在或不属于当前商户
|
||||
"""
|
||||
from printing.models import PrintingJob
|
||||
|
||||
# 获取当前用户的商户
|
||||
emp = getattr(created_by, "employee", None)
|
||||
merchant = getattr(emp, "merchant", None) if emp else None
|
||||
@@ -870,19 +947,13 @@ def create_sales_item(
|
||||
raise ValueError("用户未关联商户,无法创建销售品")
|
||||
|
||||
# 验证生产任务存在且属于当前商户
|
||||
try:
|
||||
printing_job = PrintingJob.objects.select_related("printing_order").get(
|
||||
id=printing_job_id,
|
||||
merchant=merchant,
|
||||
)
|
||||
except PrintingJob.DoesNotExist:
|
||||
raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户")
|
||||
printing_job = _get_printing_job_for_sales_item(
|
||||
printing_job_id=printing_job_id,
|
||||
merchant=merchant,
|
||||
)
|
||||
|
||||
# 转换数量为Decimal
|
||||
try:
|
||||
quantity_decimal = Decimal(str(quantity))
|
||||
except (InvalidOperation, ValueError, TypeError) as e:
|
||||
raise ValueError(f"数量 {quantity} 格式无效: {e}")
|
||||
quantity_decimal = _normalize_sales_item_quantity(quantity)
|
||||
|
||||
# 如果未提供客户ID,尝试从主订单获取
|
||||
if (
|
||||
@@ -904,6 +975,35 @@ def create_sales_item(
|
||||
customer_id=customer_id,
|
||||
remark=remark,
|
||||
position=position,
|
||||
merge_remark=merge_remark,
|
||||
)
|
||||
|
||||
return sales_item
|
||||
|
||||
|
||||
def get_merge_remark_jobs_by_main_job(
|
||||
printing_job_id: int,
|
||||
merchant,
|
||||
) -> list[list[int]]:
|
||||
"""
|
||||
查找指定 printing_job_id 作为 main_job 的所有销售品,返回每条记录的 jobs 列表。
|
||||
|
||||
同一个 printing_job_id 可能出现在多条销售品的 main_job 中,
|
||||
因此返回值是一个二维列表,每个元素对应一条匹配记录的 jobs。
|
||||
|
||||
Args:
|
||||
printing_job_id: 要查询的生产任务ID
|
||||
merchant: 当前商户
|
||||
|
||||
Returns:
|
||||
匹配记录的 jobs 列表集合,无匹配时返回空列表。
|
||||
示例: [[9777, 9776], [9777, 9780]]
|
||||
"""
|
||||
merge_remarks = list(
|
||||
get_active_sales_items_queryset()
|
||||
.filter(merchant=merchant, merge_remark__main_job=printing_job_id)
|
||||
.values_list("merge_remark", flat=True)
|
||||
)
|
||||
if not merge_remarks:
|
||||
return []
|
||||
return [mr.get("jobs", []) for mr in merge_remarks if mr]
|
||||
|
||||
Reference in New Issue
Block a user