1
0
forked from erp-dev/erp

feat: salesitem can change/soft delete

This commit is contained in:
2026-04-06 14:49:02 +08:00
parent d7bc43082e
commit 9afbb73799
16 changed files with 1075 additions and 10 deletions

View File

@@ -554,6 +554,7 @@ def _build_external_order_data(
'customer': customer,
'fabric': str(first.get('HpName') or '').strip(),
'width': str(first.get('SeHao') or '').strip(),
'area': str(first.get('area') or '').strip(),
'fabric_source': fabric_source or None,
'craft': craft or None,
'rolling_warn': str(first.get('BeiZhu') or '').strip() or None,

View File

@@ -86,6 +86,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
'ShuLiang': '10.00',
'ShuLiangZ': '2.68',
'YanSe': product_name,
'area': '周边',
'customer': {
'KhID': 'KH00999',
'KhName': '鸿烨服饰',
@@ -159,6 +160,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
self.assertEqual(job.product, product)
self.assertEqual(job.printing_order.position, r'\\fw\\2026年-LWQ15\\2026\\H鸿烨\\Tj1712#')
self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定')
self.assertEqual(job.printing_order.area, '周边')
@patch('api_v1.tasks._advance_external_printing_cursor')
@patch('api_v1.tasks._fetch_external_product_image')
@@ -186,6 +188,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358')
self.assertEqual(order.position, r'\\fw\\path\\first')
self.assertEqual(order.fabric, '120克本白四面弹单定')
self.assertEqual(order.area, '周边')
self.assertEqual(printing_models.PrintingJob.objects.count(), 1)
@patch('api_v1.tasks._advance_external_printing_cursor')
@@ -355,3 +358,30 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
self.assertEqual(job.size, '3.15')
self.assertEqual(job.pieces, 20)
self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定')
@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_order_area_updates_and_defaults_to_empty_string(
self,
mock_fetch_records,
mock_fetch_image,
mock_advance_cursor,
):
initial_record = self._build_record(record_id=1000012, product_name=self.existing_product.name)
updated_record = self._build_record(record_id=1000013, product_name=self.existing_product.name)
updated_record['area'] = ''
mock_advance_cursor.return_value = {'updated': True}
mock_fetch_records.return_value = self._build_payload([initial_record])
first_result = sync_external_printing_records.run(limit=100)
mock_fetch_records.return_value = self._build_payload([updated_record])
second_result = sync_external_printing_records.run(limit=100)
self.assertEqual(first_result['orders_created'], 1)
self.assertEqual(second_result['orders_updated'], 1)
self.assertEqual(mock_fetch_image.call_count, 0)
order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358')
self.assertEqual(order.area, '')

View File

@@ -37,6 +37,7 @@ from .views.shipment import (
SalesItemByCustomerView,
SalesItemByPrintingOrderView,
SalesItemCreateView,
ShipmentStatusUpdateView,
ShipmentDeliveryBindShipmentsView,
ShipmentDeliveryCancelView,
ShipmentDeliveryDetailView,
@@ -308,6 +309,11 @@ urlpatterns = [
ShipmentDetailView.as_view(),
name="shipment_detail",
),
path(
"shipment/shipments/<int:pk>/status/",
ShipmentStatusUpdateView.as_view(),
name="shipment_status_update",
),
path(
"shipment/shipments/external/",
ShipmentExternalCreateView.as_view(),

View File

@@ -8,6 +8,7 @@ from .views import (
SalesItemByCustomerView,
SalesItemByPrintingOrderView,
SalesItemCreateView,
ShipmentStatusUpdateView,
ShipmentDeliveryBindShipmentsView,
ShipmentDeliveryDetailView,
ShipmentDeliveryListCreateView,
@@ -24,6 +25,7 @@ __all__ = [
'SalesItemByCustomerView',
'SalesItemByPrintingOrderView',
'SalesItemCreateView',
'ShipmentStatusUpdateView',
'ShipmentDeliveryBindShipmentsView',
'ShipmentDeliveryDetailView',
'ShipmentDeliveryListCreateView',

View File

@@ -8,6 +8,7 @@ from shipment.models import (
ExternalFinishedProduct,
SalesItem,
Shipment,
ShipmentStatus,
ShipmentDelivery,
ShipmentDeliveryStatus,
)
@@ -129,7 +130,7 @@ class ShipmentSerializer(serializers.ModelSerializer):
return None
def get_items_count(self, obj):
return obj.items.count()
return obj.items.filter(delete_at__isnull=True).count()
def get_cancelled_by_name(self, obj):
if obj.cancelled_by:
@@ -156,7 +157,7 @@ class ShipmentSerializer(serializers.ModelSerializer):
"""
# 优先使用 prefetch 的 related manager兜底为 none()
rel = getattr(obj, "items", None)
items = list(rel.all()) if rel is not None else []
items = list(rel.filter(delete_at__isnull=True)) if rel is not None else []
serializer_context = dict(self.context)
serializer_context.update(_build_nested_sales_item_context(items))
return SalesItemDetailSerializer(
@@ -361,6 +362,13 @@ class ShipmentUpdateSerializer(serializers.Serializer):
)
class ShipmentStatusUpdateSerializer(serializers.Serializer):
status = serializers.ChoiceField(
choices=ShipmentStatus.choices,
help_text="出货单状态1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核)",
)
class SalesItemSerializer(serializers.Serializer):
"""
销售品序列化器(只读)
@@ -727,3 +735,34 @@ class SalesItemCreateSerializer(serializers.Serializer):
remark=validated_data.get("remark", ""),
position=validated_data.get("position", ""),
)
class SalesItemUpdateSerializer(serializers.Serializer):
"""
销售品更新序列化器。
当前仅开放数量、备注、货位的修改。
"""
quantity = serializers.CharField(
max_length=20,
required=False,
help_text="数量(可选,支持小数)",
)
remark = serializers.CharField(
max_length=200,
required=False,
allow_blank=True,
help_text="备注(可选)",
)
position = serializers.CharField(
max_length=200,
required=False,
allow_blank=True,
help_text="货位(可选)",
)
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError("至少提供一个可修改字段")
return attrs

View File

@@ -784,6 +784,115 @@ class SalesItemDetailAPITestCase(APITestCase):
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_patch_sales_item_success_and_create_change_record(self):
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"quantity": "99.50",
"remark": "改备注",
"position": "C3-08",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.sales_item.refresh_from_db()
self.assertEqual(self.sales_item.quantity, Decimal("99.50"))
self.assertEqual(self.sales_item.remark, "改备注")
self.assertEqual(self.sales_item.position, "C3-08")
change_record = self.sales_item.change_records.get()
self.assertEqual(change_record.operator_id, self.user.id)
self.assertEqual(
change_record.before_values,
{
"quantity": "88.00",
"remark": "",
"position": "B2-03",
},
)
self.assertEqual(
change_record.after_values,
{
"quantity": "99.50",
"remark": "改备注",
"position": "C3-08",
},
)
def test_patch_sales_item_rejects_relation_or_other_disallowed_fields(self):
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"name": "不允许改名",
"customer_id": self.customer.id,
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("仅允许修改以下字段", resp.json()["detail"])
self.assertFalse(self.sales_item.change_records.exists())
def test_patch_sales_item_invalid_quantity(self):
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"quantity": "abc",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("数量 abc 格式无效", resp.json()["detail"])
self.assertFalse(self.sales_item.change_records.exists())
def test_patch_sales_item_other_merchant_404(self):
self.client.force_authenticate(user=self.other_user)
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"remark": "无权限修改",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_delete_sales_item_requires_permission(self):
resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
self.sales_item.refresh_from_db()
self.assertIsNone(self.sales_item.delete_at)
self.assertIsNone(self.sales_item.delete_by)
def test_delete_sales_item_success_soft_deletes_and_hides_detail(self):
permission = Permission.objects.get(codename="soft_delete_salesitem")
self.user.user_permissions.add(permission)
resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.json()["detail"], "销售品已标记为删除")
self.sales_item.refresh_from_db()
self.assertIsNotNone(self.sales_item.delete_at)
self.assertEqual(self.sales_item.delete_by, self.user)
detail_resp = self.client.get(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(detail_resp.status_code, status.HTTP_404_NOT_FOUND)
def test_delete_sales_item_other_merchant_404(self):
permission = Permission.objects.get(codename="soft_delete_salesitem")
self.other_user.user_permissions.add(permission)
self.client.force_authenticate(user=self.other_user)
resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
class ShipmentCreateAPITestCase(TestCase):
"""测试创建出货单 API"""
@@ -1683,6 +1792,166 @@ class ShipmentStatusServiceTestCase(TestCase):
)
class ShipmentStatusAPITestCase(APITestCase):
"""测试出货单状态流转 API"""
def setUp(self):
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name="状态 API 商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.other_merchant = basic_models.Merchant.objects.create(
name="状态 API 其它商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.user = User.objects.create_user(
username="shipment_status_api_user",
password="testpass123",
email="shipment_status_api@example.com",
)
self.employee = basic_models.Employee.objects.create(
sys_user=self.user,
merchant=self.merchant,
name="状态 API 员工",
mobile="13800138101",
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
self.other_user = User.objects.create_user(
username="shipment_status_api_other_user",
password="testpass123",
email="shipment_status_api_other@example.com",
)
self.other_employee = basic_models.Employee.objects.create(
sys_user=self.other_user,
merchant=self.other_merchant,
name="状态 API 其它员工",
mobile="13800138102",
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name="状态 API 客户",
mobile="13900139101",
area="杭州",
)
self.shipment = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-02",
created_by=self.user,
)
self.client.force_authenticate(user=self.user)
def test_patch_status_draft_to_published_success(self):
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.PUBLISHED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.PUBLISHED)
self.assertIsNotNone(self.shipment.status_modified_at)
self.assertIsNone(self.shipment.approved_by)
self.assertIsNone(self.shipment.cancelled_by)
def test_put_status_published_to_approved_sets_approved_by(self):
from shipment.services import modify_status
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
resp = self.client.put(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.APPROVED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED)
self.assertEqual(self.shipment.approved_by, self.user)
def test_patch_status_published_to_rejected_success(self):
from shipment.services import modify_status
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.REJECTED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.REJECTED)
def test_patch_status_to_cancelled_sets_cancelled_by(self):
from shipment.services import modify_status
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.CANCELLED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.CANCELLED)
self.assertEqual(self.shipment.cancelled_by, self.user)
def test_patch_status_rejects_invalid_transition(self):
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.APPROVED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("不允许将出货单状态从 草稿(未发布) 修改为 已审核", resp.json()["detail"])
def test_patch_status_other_merchant_404(self):
self.client.force_authenticate(user=self.other_user)
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.PUBLISHED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_patch_status_unauthenticated(self):
self.client.logout()
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.PUBLISHED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
class SalesItemCreateAPITestCase(APITestCase):
"""销售品创建 API 测试"""

View File

@@ -15,6 +15,8 @@ from shipment.models import Shipment, ShipmentDelivery
from .serializers import (
SalesItemDetailSerializer,
SalesItemSerializer,
SalesItemUpdateSerializer,
ShipmentStatusUpdateSerializer,
ShipmentDeliveryBindShipmentsSerializer,
ShipmentDeliveryCreateSerializer,
ShipmentDeliverySerializer,
@@ -274,6 +276,69 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
return self.patch(request, pk=pk)
class ShipmentStatusUpdateView(APIView):
"""
更新出货单状态
PATCH /api/v1/shipment/shipments/<id>/status/
PUT /api/v1/shipment/shipments/<id>/status/
"""
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = Shipment.objects.all().select_related(
"merchant",
"customer",
"created_by",
"cancelled_by",
"approved_by",
).prefetch_related(
"items",
"external_finished_products",
)
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 Shipment.objects.none()
return qs.filter(merchant=merchant)
def patch(self, request, pk: int):
shipment = self.get_queryset().filter(id=pk).first()
if shipment is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
serializer = ShipmentStatusUpdateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.models import ShipmentStatus
from shipment.services import modify_status
target_status = serializer.validated_data["status"]
approved_by = request.user if target_status == ShipmentStatus.APPROVED else None
try:
shipment = modify_status(
shipment,
target_status=target_status,
operator=request.user,
approved_by=approved_by,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK)
def put(self, request, pk: int):
return self.patch(request, pk=pk)
class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView):
"""
送货单:查询列表 / 创建
@@ -912,6 +977,7 @@ class SalesItemDetailView(GenericAPIView):
销售品详情。
GET /api/v1/shipment/sales-items/<id>/
PATCH /api/v1/shipment/sales-items/<id>/
"""
permission_classes = [IsAuthenticated]
@@ -920,7 +986,11 @@ class SalesItemDetailView(GenericAPIView):
def get_queryset(self):
from shipment.models import SalesItem
qs = SalesItem.objects.select_related("shipment", "created_by").order_by("id")
qs = (
SalesItem.objects.select_related("shipment", "created_by", "delete_by")
.filter(delete_at__isnull=True)
.order_by("id")
)
user = self.request.user
if getattr(user, "is_superuser", False):
@@ -945,3 +1015,64 @@ class SalesItemDetailView(GenericAPIView):
},
)
return Response(serializer.data)
def patch(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)
allowed_fields = {"quantity", "remark", "position"}
unexpected_fields = sorted(set(request.data.keys()) - allowed_fields)
if unexpected_fields:
return Response(
{
"detail": (
"销售品仅允许修改以下字段: "
f"{', '.join(sorted(allowed_fields))}"
f"不支持字段: {', '.join(unexpected_fields)}"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = SalesItemUpdateSerializer(data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.services import update_sales_item
try:
sales_item = update_sales_item(
sales_item,
quantity=serializer.validated_data.get("quantity"),
remark=serializer.validated_data.get("remark"),
position=serializer.validated_data.get("position"),
operator=request.user,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
response_serializer = self.get_serializer(
sales_item,
context={
**_build_sales_item_serializer_context([sales_item]),
"request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
def delete(self, request, pk: int):
if not request.user.has_perm("shipment.soft_delete_salesitem"):
return Response(
{"detail": "没有权限删除销售品"},
status=status.HTTP_403_FORBIDDEN,
)
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)
from shipment.services import delete_sales_item
delete_sales_item(sales_item, deleted_by=request.user)
return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK)

View File

@@ -40,6 +40,7 @@
| `KhID` | 外部客户 ID | `PrintingOrder.external_customer_id` | 原样保存 |
| `customer.KhName` | 外部客户名 | `PrintingOrder.external_customer_name` | 原样保存 |
| `HpName` | 布料名 | `PrintingOrder.fabric` | 原样保存到订单面料字段 |
| `area` | 地区/区域 | `PrintingOrder.area` | 原样保存;取不到时回退为空字符串 `""` |
| `CaoZY` | 外部操作员/业务员 | `PrintingOrder.created_by` / `PrintingOrder.external_employee_name` | 若能匹配到内部员工且员工绑定了系统用户,则 `created_by` 取该用户,`external_employee_name` 置空;否则 `created_by` 取固定同步用户,`external_employee_name` 保留原文 |
| `SHDZ` | 布料来源 + 工艺 | `PrintingOrder.fabric_source` / `PrintingOrder.craft` | 以空白字符 split第 1 段为 `fabric_source`,剩余重新拼接为 `craft` |
| `SeHao` | 幅宽 | `PrintingOrder.width` | 原样保存 |
@@ -52,6 +53,7 @@
补充说明:
- 外部 `HpName` 已作为布料名使用,直接写入 `PrintingOrder.fabric`
- 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串
- `BianHaoKD``RiQi` 等当前未单独属性化的字段,保留在 `external_raw`
- `BianHaoKD` 当前样例值类似 `"1.27"`,不按日期强解析

View File

@@ -0,0 +1,164 @@
# SalesItem 修改与删除 API 文档
本文档说明 `SalesItem` 销售品的修改与软删除接口。
## 修改接口
### 接口信息
- URL: `/api/v1/shipment/sales-items/{id}/`
- Method: `PATCH`
- 认证: 需要登录JWT Token
说明:
- 该接口仅允许修改部分非关系字段
- 当前允许修改的字段只有:
- `quantity`
- `remark`
- `position`
- 不允许修改 `name``unit``shipment``customer_id``printing_job_id` 等字段
- 成功修改后会自动写入 `SalesItemChangeRecord`
### 请求体示例
```json
{
"quantity": "120.50",
"remark": "补充备注",
"position": "A3-02"
}
```
### 成功响应示例
```json
{
"id": 15,
"name": "测试销售品",
"quantity": "120.50",
"unit": 1,
"unit_display": "米",
"position": "A3-02",
"remark": "补充备注",
"printing_job_id": 123,
"printing_order_id": 45,
"external_order_id": "EXT-2026-001",
"customer_id": 8,
"customer_name": "客户A",
"shipment_id": null,
"shipment_date": null,
"created_at": "2026-04-06T10:00:00+08:00",
"created_by_id": 3,
"created_by_name": "测试员工",
"product_image_url": null
}
```
### 修改审计记录
每次成功修改时,会新增一条 `SalesItemChangeRecord`,记录:
- `sales_item`
- `operator`
- `operated_at`
- `before_values`
- `after_values`
### 错误响应示例
#### 1. 传入了不允许修改的字段
```json
{
"detail": "销售品仅允许修改以下字段: position, quantity, remark不支持字段: customer_id, name"
}
```
#### 2. 数量格式非法
```json
{
"detail": "数量 abc 格式无效: [具体异常信息]"
}
```
#### 3. 对象不存在或无权访问
```json
{
"detail": "Not found."
}
```
## 删除接口
### 接口信息
- URL: `/api/v1/shipment/sales-items/{id}/`
- Method: `DELETE`
- 认证: 需要登录JWT Token
说明:
- 该接口为软删除,不会物理删除数据库记录
- 删除后销售品会从常规查询接口中隐藏
- 删除操作需要独立 Django 权限:`shipment.soft_delete_salesitem`
### 软删除字段
`SalesItem` 通过以下字段记录删除信息:
- `delete_at`: 删除时间
- `delete_by`: 删除人
### 权限要求
调用方需要具备:
- `shipment.soft_delete_salesitem`
无该权限时返回 `403 Forbidden`
### 请求示例
```http
DELETE /api/v1/shipment/sales-items/15/
Authorization: Bearer <token>
```
### 成功响应
```json
{
"detail": "销售品已标记为删除"
}
```
### 行为说明
- 软删除后,`SalesItem` 不再出现在以下常规接口结果中:
- 销售品详情
- 按客户查询销售品
- 按生产订单查询销售品
- 未出货销售品客户反查
- 出货单详情 / 列表中的嵌套销售品
- 重复删除当前实现保持幂等;由于对象在常规查询中已隐藏,后续再次调用通常会得到 `404`
### 错误响应示例
#### 1. 无权限
```json
{
"detail": "没有权限删除销售品"
}
```
#### 2. 对象不存在或无权访问
```json
{
"detail": "Not found."
}
```

View File

@@ -5,6 +5,8 @@
## 目录
- [查询出货单](#查询出货单)
- [出货单状态流转接口](./shipment_status_api.md)
- [销售品删除接口](./sales_item_delete_api.md)
- [创建出货单](#创建出货单)
- [查询有待出货销售品的客户](#查询有待出货销售品的客户)
- [按客户查询销售品](#按客户查询销售品)

132
docs/shipment_status_api.md Normal file
View File

@@ -0,0 +1,132 @@
# Shipment Status API 文档
本文档仅说明 `Shipment` 出货单状态流转接口。
## 接口信息
- URL: `/api/v1/shipment/shipments/{id}/status/`
- Method: `PATCH` / `PUT`
- 认证: 需要登录JWT Token
说明:
- 该接口只负责修改 `Shipment.status`
- 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/`
- `PATCH``PUT` 当前行为一致,都是按请求体中的 `status` 执行状态流转
## 请求体
```json
{
"status": 2
}
```
字段说明:
- `status`: 目标状态
- `1 = 草稿(未发布)`
- `2 = 已发布`
- `3 = 已取消`
- `4 = 已驳回`
- `5 = 已审核`
## 状态机规则
- `草稿(未发布)` 只能流转到 `已发布`
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
- `已驳回` 可以流转到 `已审核` / `已取消`
- `已审核` 可以流转到 `已取消`
- `已取消` 不可再流转
- 重复设置同一状态时保持幂等
附加规则:
- 进入 `已审核` 时,接口会自动将当前 `request.user` 写入 `approved_by`
- 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by`
- 每次成功状态流转都会更新 `status_modified_at`
## 请求示例
### 1. 草稿发布
```http
PATCH /api/v1/shipment/shipments/12/status/
Content-Type: application/json
{
"status": 2
}
```
### 2. 已发布审核
```http
PUT /api/v1/shipment/shipments/12/status/
Content-Type: application/json
{
"status": 5
}
```
## 成功响应示例
```json
{
"id": 12,
"merchant_id": 1,
"merchant_name": "测试印花厂",
"customer": 8,
"customer_name": "客户A",
"shipment_date": "2026-04-06",
"address": "",
"contact_name": "",
"contact_phone": "",
"area": "",
"remark": "",
"status": 5,
"status_display": "已审核",
"external_id": null,
"status_modified_at": "2026-04-06T14:30:00+08:00",
"cancelled_by_id": null,
"cancelled_by_name": null,
"approved_by_id": 3,
"approved_by_name": "测试员工",
"items_count": 2,
"sales_items": [],
"external_finished_products": [],
"created_by_id": 3,
"created_by_name": "测试员工",
"created_at": "2026-04-06T10:00:00+08:00",
"updated_at": "2026-04-06T14:30:00+08:00"
}
```
## 错误响应示例
### 1. 非法流转
```json
{
"detail": "不允许将出货单状态从 草稿(未发布) 修改为 已审核"
}
```
### 2. 越权或对象不存在
```json
{
"detail": "Not found."
}
```
### 3. 请求体不合法
```json
{
"status": [
"\"99\" is not a valid choice."
]
}
```

View File

@@ -1,5 +1,11 @@
from django.contrib import admin
from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery
from .models import (
ExternalFinishedProduct,
SalesItem,
SalesItemChangeRecord,
Shipment,
ShipmentDelivery,
)
class SalesItemInline(admin.TabularInline):
@@ -64,10 +70,10 @@ class ShipmentAdmin(admin.ModelAdmin):
@admin.register(SalesItem)
class SalesItemAdmin(admin.ModelAdmin):
list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'created_at']
list_filter = ['unit', 'created_at', ('shipment', admin.EmptyFieldListFilter)]
list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'delete_at', 'delete_by', 'created_at']
list_filter = ['unit', 'created_at', 'delete_at', ('shipment', admin.EmptyFieldListFilter)]
search_fields = ['name', 'shipment__customer__name', 'position', 'remark']
readonly_fields = ['created_at', 'updated_at', 'created_by']
readonly_fields = ['created_at', 'updated_at', 'created_by', 'delete_at', 'delete_by']
raw_id_fields = ['shipment']
def shipment_status(self, obj):
@@ -96,6 +102,32 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin):
super().save_model(request, obj, form, change)
@admin.register(SalesItemChangeRecord)
class SalesItemChangeRecordAdmin(admin.ModelAdmin):
list_display = [
"id",
"sales_item",
"operator",
"operated_at",
"created_at",
]
list_filter = ["operated_at", "created_at"]
search_fields = [
"sales_item__name",
"operator__username",
"operator__employee__name",
]
readonly_fields = [
"sales_item",
"operator",
"operated_at",
"before_values",
"after_values",
"created_at",
"updated_at",
]
@admin.register(ShipmentDelivery)
class ShipmentDeliveryAdmin(admin.ModelAdmin):
list_display = [

View File

@@ -0,0 +1,59 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0016_shipmentdelivery_contact_phone_and_vehicle_capacity"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SalesItemChangeRecord",
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="更新时间")),
("operated_at", models.DateTimeField(verbose_name="操作时间")),
("before_values", models.JSONField(blank=True, default=dict, verbose_name="修改前的值")),
("after_values", models.JSONField(blank=True, default=dict, verbose_name="修改目标值")),
(
"operator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="sales_item_change_records",
to=settings.AUTH_USER_MODEL,
verbose_name="操作者",
),
),
(
"sales_item",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="change_records",
to="shipment.salesitem",
verbose_name="销售品",
),
),
],
options={
"verbose_name": "销售品修改记录",
"verbose_name_plural": "销售品修改记录",
"db_table": "sales_item_change_record",
"ordering": ["-operated_at", "-id"],
},
),
]

View File

@@ -0,0 +1,40 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0017_salesitemchangerecord"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="salesitem",
name="delete_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="删除时间"),
),
migrations.AddField(
model_name="salesitem",
name="delete_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="deleted_sales_items",
to=settings.AUTH_USER_MODEL,
verbose_name="删除人",
),
),
migrations.AlterModelOptions(
name="salesitem",
options={
"ordering": ["id"],
"permissions": [("soft_delete_salesitem", "Can soft delete sales item")],
"verbose_name": "销售品",
"verbose_name_plural": "销售品",
},
),
]

View File

@@ -302,12 +302,30 @@ class SalesItem(ModelBase):
default='',
verbose_name='备注'
)
delete_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='删除时间',
)
delete_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_sales_items',
verbose_name='删除人',
)
class Meta:
db_table = 'sales_item'
verbose_name = '销售品'
verbose_name_plural = '销售品'
ordering = ['id']
permissions = [
('soft_delete_salesitem', 'Can soft delete sales item'),
]
def __str__(self):
return f'{self.name} x {self.quantity} {self.get_unit_display()}'
@@ -342,6 +360,55 @@ class SalesItem(ModelBase):
return None
class SalesItemChangeRecord(ModelBase):
"""
销售品修改记录。
记录允许修改字段的变更前后值,以及操作者与操作时间。
"""
sales_item = models.ForeignKey(
SalesItem,
on_delete=models.CASCADE,
related_name="change_records",
verbose_name="销售品",
)
operator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="sales_item_change_records",
verbose_name="操作者",
)
operated_at = models.DateTimeField(
verbose_name="操作时间",
)
before_values = models.JSONField(
default=dict,
blank=True,
verbose_name="修改前的值",
)
after_values = models.JSONField(
default=dict,
blank=True,
verbose_name="修改目标值",
)
class Meta:
db_table = "sales_item_change_record"
verbose_name = "销售品修改记录"
verbose_name_plural = "销售品修改记录"
ordering = ["-operated_at", "-id"]
def __str__(self) -> str:
return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}"
class ShipmentDelivery(ModelBase):
"""
送货单

View File

@@ -4,6 +4,7 @@ Shipment 模块业务逻辑层
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import List
from django.db import transaction
@@ -14,6 +15,7 @@ from django.utils import timezone
from shipment.models import (
ExternalFinishedProduct,
SalesItem,
SalesItemChangeRecord,
Shipment,
ShipmentDelivery,
ShipmentDeliveryStatus,
@@ -82,6 +84,7 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
base_sales_items = SalesItem.objects.filter(
merchant=merchant,
shipment__isnull=True,
delete_at__isnull=True,
customer_id=OuterRef("pk"),
)
count_subquery = (
@@ -153,6 +156,7 @@ def get_sales_items_by_printing_order(
# 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids))
queryset = queryset.filter(delete_at__isnull=True)
# 3. 根据参数决定是否过滤已出货的销售品
if not include_already_has_shipment:
@@ -176,6 +180,7 @@ def get_sales_items_by_customer(
queryset = SalesItem.objects.filter(
merchant=merchant,
customer_id=customer_id,
delete_at__isnull=True,
)
if external_order_id:
@@ -193,6 +198,91 @@ def get_sales_items_by_customer(
return queryset.select_related("shipment").order_by("id")
@transaction.atomic
def update_sales_item(
sales_item: SalesItem,
*,
quantity: str | Decimal | None = None,
remark: str | None = None,
position: str | None = None,
operator=None,
) -> SalesItem:
"""
更新销售品的非关系字段。
当前仅允许修改:
- quantity
- remark
- position
"""
changes: dict[str, tuple[object, object]] = {}
if quantity is not None:
try:
normalized_quantity = Decimal(str(quantity))
except (InvalidOperation, ValueError, TypeError) as exc:
raise ValueError(f"数量 {quantity} 格式无效: {exc}")
if normalized_quantity != sales_item.quantity:
changes["quantity"] = (sales_item.quantity, normalized_quantity)
sales_item.quantity = normalized_quantity
if remark is not None:
normalized_remark = remark or ""
if normalized_remark != sales_item.remark:
changes["remark"] = (sales_item.remark, normalized_remark)
sales_item.remark = normalized_remark
if position is not None:
normalized_position = position or ""
if normalized_position != sales_item.position:
changes["position"] = (sales_item.position, normalized_position)
sales_item.position = normalized_position
if not changes:
return sales_item
sales_item.save(update_fields=[*changes.keys(), "updated_at"])
def _serialize_value(value):
if isinstance(value, Decimal):
return str(value)
return value
SalesItemChangeRecord.objects.create(
sales_item=sales_item,
operator=operator,
operated_at=timezone.now(),
before_values={
field: _serialize_value(old_value)
for field, (old_value, _) in changes.items()
},
after_values={
field: _serialize_value(new_value)
for field, (_, new_value) in changes.items()
},
)
return sales_item
@transaction.atomic
def delete_sales_item(
sales_item: SalesItem,
*,
deleted_by=None,
) -> SalesItem:
"""
软删除销售品。
"""
if sales_item.delete_at is not None:
return sales_item
sales_item.delete_at = timezone.now()
sales_item.delete_by = deleted_by
sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"])
return sales_item
@transaction.atomic
def create_shipment_delivery(
*,
@@ -579,7 +669,7 @@ def create_shipment(
from printing.models import PrintingJob
# 查询销售品
sales_items = SalesItem.objects.filter(id__in=sales_item_ids)
sales_items = SalesItem.objects.filter(id__in=sales_item_ids, delete_at__isnull=True)
found_ids = set(sales_items.values_list("id", flat=True))
missing_ids = set(sales_item_ids) - found_ids
@@ -642,7 +732,7 @@ def create_shipment(
# 关联销售品
if sales_item_ids:
updated = SalesItem.objects.filter(
id__in=sales_item_ids, merchant=merchant
id__in=sales_item_ids, merchant=merchant, delete_at__isnull=True
).update(shipment=shipment)
if updated != len(sales_item_ids):
raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")
@@ -752,7 +842,6 @@ def create_sales_item(
Raises:
ValueError: 如果生产任务不存在或不属于当前商户
"""
from decimal import Decimal, InvalidOperation
from printing.models import PrintingJob
# 获取当前用户的商户