1
0
forked from erp-dev/erp

feat: retry haobuye sync order

This commit is contained in:
2026-04-07 16:56:46 +08:00
parent ebd687ccdd
commit 76ea445880
15 changed files with 1098 additions and 118 deletions

View File

@@ -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)))

View File

@@ -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))

View File

@@ -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"
),

View File

@@ -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',

View File

@@ -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="新的数量(可选,未传则沿用原销售品数量)",
)

View File

@@ -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"""

View File

@@ -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,
)

View File

@@ -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 不存在或不属于当前商户"
}
```
## 删除接口
### 接口信息

View File

@@ -0,0 +1,222 @@
# 销售品合卷备注merge_remarkAPI 变更说明
> 变更日期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[] | 参与本次合卷的所有子单IDprinting_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` 即可区分是否为合卷销售品。

View File

@@ -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

View File

@@ -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 = [

View 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"],
},
),
]

View 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='合卷备注'),
),
]

View File

@@ -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):
"""
送货单

View File

@@ -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]