forked from erp-dev/erp
feat: shipment patch
This commit is contained in:
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"flower-erp-users-http": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8099/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
||||
source="cancelled_by.id", read_only=True, allow_null=True
|
||||
)
|
||||
cancelled_by_name = serializers.SerializerMethodField()
|
||||
approved_by_id = serializers.IntegerField(
|
||||
source="approved_by.id", read_only=True, allow_null=True
|
||||
)
|
||||
approved_by_name = serializers.SerializerMethodField()
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
external_finished_products_count = serializers.SerializerMethodField()
|
||||
merchant_id = serializers.IntegerField(source="merchant.id", read_only=True)
|
||||
@@ -43,9 +47,11 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
||||
"status",
|
||||
"status_display",
|
||||
"external_id",
|
||||
"cancelled_at",
|
||||
"status_modified_at",
|
||||
"cancelled_by_id",
|
||||
"cancelled_by_name",
|
||||
"approved_by_id",
|
||||
"approved_by_name",
|
||||
"items_count",
|
||||
"created_by_id",
|
||||
"created_by_name",
|
||||
@@ -79,6 +85,14 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
||||
def get_external_finished_products_count(self, obj):
|
||||
return obj.external_finished_products.count()
|
||||
|
||||
def get_approved_by_name(self, obj):
|
||||
if obj.approved_by:
|
||||
employee = getattr(obj.approved_by, "employee", None)
|
||||
if employee:
|
||||
return employee.name
|
||||
return obj.approved_by.username
|
||||
return None
|
||||
|
||||
def get_sales_items(self, obj):
|
||||
"""
|
||||
出货单关联的销售品明细(无则返回空数组)。
|
||||
|
||||
@@ -817,12 +817,70 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
area="测试地区",
|
||||
)
|
||||
|
||||
self.state1 = stateflow_models.State.objects.create(name="待印染")
|
||||
self.state2 = stateflow_models.State.objects.create(name="印染中")
|
||||
self.process = stateflow_models.Process.objects.create(name="印染流程")
|
||||
self.process.replace_nodes([self.state1, self.state2])
|
||||
|
||||
self.category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="测试分类",
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name="测试产品",
|
||||
human_id="SHIPTEST001",
|
||||
)
|
||||
|
||||
self.printing_order = printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
fabric="测试面料",
|
||||
width="150cm",
|
||||
process=self.process,
|
||||
created_by=self.user,
|
||||
)
|
||||
self.other_printing_order = printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
fabric="测试面料2",
|
||||
width="160cm",
|
||||
process=self.process,
|
||||
created_by=self.user,
|
||||
)
|
||||
self.printing_job1 = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.merchant,
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=100,
|
||||
unit="米",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.printing_job2 = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.merchant,
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=80,
|
||||
unit="米",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.printing_job_other = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.merchant,
|
||||
printing_order=self.other_printing_order,
|
||||
product=self.product,
|
||||
quantity=60,
|
||||
unit="米",
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
# 创建销售品(未关联出货单)
|
||||
self.sales_item1 = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="销售品1",
|
||||
quantity=Decimal("50.00"),
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
printing_job_id=self.printing_job1.id,
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
@@ -831,6 +889,24 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
name="销售品2",
|
||||
quantity=Decimal("30.00"),
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
printing_job_id=self.printing_job2.id,
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
self.sales_item_other_order = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="销售品4(不同生产单)",
|
||||
quantity=Decimal("20.00"),
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
printing_job_id=self.printing_job_other.id,
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
self.sales_item_missing_job = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="销售品5(缺少生产任务)",
|
||||
quantity=Decimal("10.00"),
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
@@ -846,6 +922,7 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
name="销售品3(已出货)",
|
||||
quantity=Decimal("100.00"),
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
printing_job_id=self.printing_job1.id,
|
||||
shipment=self.existing_shipment,
|
||||
created_by=self.user,
|
||||
)
|
||||
@@ -875,6 +952,8 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
self.assertEqual(result["shipment_date"], "2026-01-14")
|
||||
self.assertEqual(result.get("area", ""), "华东")
|
||||
self.assertEqual(result["remark"], "测试备注")
|
||||
self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT)
|
||||
self.assertEqual(result["status_display"], "草稿(未发布)")
|
||||
self.assertEqual(result["items_count"], 2)
|
||||
self.assertEqual(result["created_by_id"], self.user.id)
|
||||
|
||||
@@ -896,6 +975,8 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
result = response.json()
|
||||
self.assertEqual(result["items_count"], 0)
|
||||
self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT)
|
||||
self.assertEqual(result["status_display"], "草稿(未发布)")
|
||||
|
||||
def test_create_shipment_customer_not_found(self):
|
||||
"""测试客户不存在"""
|
||||
@@ -935,6 +1016,32 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("已关联", response.json()["detail"])
|
||||
|
||||
def test_create_shipment_rejects_sales_items_from_different_printing_orders(self):
|
||||
"""测试销售品来自不同生产订单时拒绝创建"""
|
||||
data = {
|
||||
"customer": self.customer.id,
|
||||
"shipment_date": "2026-01-14",
|
||||
"sales_items": [self.sales_item1.id, self.sales_item_other_order.id],
|
||||
}
|
||||
|
||||
response = self.client.post("/api/v1/shipment/shipments/", data, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("同一个生产订单", response.json()["detail"])
|
||||
|
||||
def test_create_shipment_rejects_sales_item_without_printing_job(self):
|
||||
"""测试销售品缺少生产任务时拒绝创建"""
|
||||
data = {
|
||||
"customer": self.customer.id,
|
||||
"shipment_date": "2026-01-14",
|
||||
"sales_items": [self.sales_item_missing_job.id],
|
||||
}
|
||||
|
||||
response = self.client.post("/api/v1/shipment/shipments/", data, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("缺少关联生产任务", response.json()["detail"])
|
||||
|
||||
def test_create_shipment_unauthenticated(self):
|
||||
"""测试未认证用户"""
|
||||
self.client.logout()
|
||||
@@ -1017,6 +1124,8 @@ class ShipmentExternalCreateAPITestCase(TestCase):
|
||||
self.assertEqual(result["customer"], self.customer.id)
|
||||
self.assertEqual(result["external_id"], "EXT-ORDER-001")
|
||||
self.assertEqual(result.get("area", ""), "华南")
|
||||
self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT)
|
||||
self.assertEqual(result["status_display"], "草稿(未发布)")
|
||||
self.assertEqual(result["items_count"], 0)
|
||||
self.assertEqual(result["external_finished_products_count"], 2)
|
||||
|
||||
@@ -1160,6 +1269,8 @@ class ShipmentQueryAPITestCase(TestCase):
|
||||
item = next(it for it in data["results"] if it["id"] == self.shipment1.id)
|
||||
self.assertIn("area", item)
|
||||
self.assertEqual(item["area"], "A1")
|
||||
self.assertEqual(item["status"], shipment_models.ShipmentStatus.DRAFT)
|
||||
self.assertEqual(item["status_display"], "草稿(未发布)")
|
||||
self.assertIn("sales_items", item)
|
||||
self.assertIsInstance(item["sales_items"], list)
|
||||
self.assertIn("external_finished_products", item)
|
||||
@@ -1172,6 +1283,8 @@ class ShipmentQueryAPITestCase(TestCase):
|
||||
self.assertEqual(result["id"], self.shipment1.id)
|
||||
self.assertIn("area", result)
|
||||
self.assertEqual(result["area"], "A1")
|
||||
self.assertEqual(result["status"], shipment_models.ShipmentStatus.DRAFT)
|
||||
self.assertEqual(result["status_display"], "草稿(未发布)")
|
||||
self.assertIn("sales_items", result)
|
||||
self.assertIsInstance(result["sales_items"], list)
|
||||
self.assertIn("external_finished_products", result)
|
||||
@@ -1221,12 +1334,258 @@ class ShipmentQueryAPITestCase(TestCase):
|
||||
resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment2.id}/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_list_shipments_supports_status_filter(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment1,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user1,
|
||||
)
|
||||
|
||||
resp = self.client.get(
|
||||
f"/api/v1/shipment/shipments/?status={shipment_models.ShipmentStatus.PUBLISHED}"
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["results"][0]["id"], self.shipment1.id)
|
||||
|
||||
def test_retrieve_shipment_supports_status_filter(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment1,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user1,
|
||||
)
|
||||
|
||||
resp = self.client.get(
|
||||
f"/api/v1/shipment/shipments/{self.shipment1.id}/?status={shipment_models.ShipmentStatus.PUBLISHED}"
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.json()["id"], self.shipment1.id)
|
||||
|
||||
def test_retrieve_shipment_status_filter_mismatch_returns_404(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment1,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user1,
|
||||
)
|
||||
|
||||
resp = self.client.get(
|
||||
f"/api/v1/shipment/shipments/{self.shipment1.id}/?status={shipment_models.ShipmentStatus.APPROVED}"
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_list_shipments_unauthenticated(self):
|
||||
self.client.logout()
|
||||
resp = self.client.get("/api/v1/shipment/shipments/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class ShipmentStatusServiceTestCase(TestCase):
|
||||
"""测试 shipment.services.modify_status 状态机"""
|
||||
|
||||
def setUp(self):
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name="状态测试商户", type=basic_models.MerchantTypeEnum.FACTORY
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="shipment_status_user",
|
||||
password="testpass123",
|
||||
email="shipment_status@example.com",
|
||||
)
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
sys_user=self.user,
|
||||
merchant=self.merchant,
|
||||
name="状态测试员工",
|
||||
mobile="13800138100",
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="状态测试客户",
|
||||
mobile="13900139100",
|
||||
area="杭州",
|
||||
)
|
||||
self.shipment = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
shipment_date="2026-04-02",
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
def test_modify_status_keeps_idempotent_when_same_status(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
original_updated_at = self.shipment.updated_at
|
||||
original_status_modified_at = self.shipment.status_modified_at
|
||||
|
||||
returned = modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.DRAFT,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
self.assertEqual(returned.id, self.shipment.id)
|
||||
self.shipment.refresh_from_db()
|
||||
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.DRAFT)
|
||||
self.assertEqual(self.shipment.status_modified_at, original_status_modified_at)
|
||||
self.assertEqual(self.shipment.updated_at, original_updated_at)
|
||||
|
||||
def test_modify_status_allows_draft_to_published(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
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.cancelled_by)
|
||||
self.assertIsNone(self.shipment.approved_by)
|
||||
|
||||
def test_modify_status_rejects_draft_to_approved(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "不允许将出货单状态从 草稿(未发布) 修改为 已审核"):
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.APPROVED,
|
||||
operator=self.user,
|
||||
approved_by=self.user,
|
||||
)
|
||||
|
||||
def test_modify_status_requires_approved_by_when_approving(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "approved_by 不能为空"):
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.APPROVED,
|
||||
operator=self.user,
|
||||
approved_by=None,
|
||||
)
|
||||
|
||||
def test_modify_status_sets_approved_by_when_approved(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.APPROVED,
|
||||
operator=self.user,
|
||||
approved_by=self.user,
|
||||
)
|
||||
|
||||
self.shipment.refresh_from_db()
|
||||
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED)
|
||||
self.assertEqual(self.shipment.approved_by, self.user)
|
||||
self.assertIsNotNone(self.shipment.status_modified_at)
|
||||
|
||||
def test_modify_status_allows_rejected_to_approved(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.REJECTED,
|
||||
operator=self.user,
|
||||
)
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.APPROVED,
|
||||
operator=self.user,
|
||||
approved_by=self.user,
|
||||
)
|
||||
|
||||
self.shipment.refresh_from_db()
|
||||
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED)
|
||||
self.assertEqual(self.shipment.approved_by, self.user)
|
||||
|
||||
def test_modify_status_rejects_rejected_to_published(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.REJECTED,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "不允许将出货单状态从 已驳回 修改为 已发布"):
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
def test_modify_status_allows_cancel_from_any_non_cancelled_state(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.CANCELLED,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
self.shipment.refresh_from_db()
|
||||
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.CANCELLED)
|
||||
self.assertEqual(self.shipment.cancelled_by, self.user)
|
||||
self.assertIsNotNone(self.shipment.status_modified_at)
|
||||
|
||||
def test_modify_status_rejects_change_after_cancelled(self):
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.PUBLISHED,
|
||||
operator=self.user,
|
||||
)
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.CANCELLED,
|
||||
operator=self.user,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "不允许将出货单状态从 已取消 修改为 已审核"):
|
||||
modify_status(
|
||||
self.shipment,
|
||||
target_status=shipment_models.ShipmentStatus.APPROVED,
|
||||
operator=self.user,
|
||||
approved_by=self.user,
|
||||
)
|
||||
|
||||
|
||||
class SalesItemCreateAPITestCase(APITestCase):
|
||||
"""销售品创建 API 测试"""
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
|
||||
|
||||
支持过滤参数(可选):
|
||||
- customer: 客户ID
|
||||
- status: 状态(1/2/3)
|
||||
- status: 状态(1=草稿, 2=已发布, 3=已取消, 4=已驳回, 5=已审核)
|
||||
- external_id: 外部订单号(精确匹配)
|
||||
- shipment_date_from: 出货日期起始(YYYY-MM-DD)
|
||||
- shipment_date_to: 出货日期结束(YYYY-MM-DD,包含整天)
|
||||
@@ -217,7 +217,13 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
|
||||
merchant = getattr(emp, "merchant", None) if emp else None
|
||||
if not merchant:
|
||||
return Shipment.objects.none()
|
||||
return qs.filter(merchant=merchant)
|
||||
qs = qs.filter(merchant=merchant)
|
||||
|
||||
status_val = self.request.query_params.get("status")
|
||||
if status_val:
|
||||
qs = qs.filter(status=status_val)
|
||||
|
||||
return qs
|
||||
|
||||
def get(self, request, pk: int):
|
||||
return self.retrieve(request, pk=pk)
|
||||
@@ -446,8 +452,9 @@ class SalesItemByPrintingOrderView(APIView):
|
||||
from shipment.services import get_sales_items_by_printing_order
|
||||
|
||||
sales_items = get_sales_items_by_printing_order(
|
||||
printing_order_id=printing_order.id,
|
||||
printing_order_id=printing_order_id,
|
||||
include_already_has_shipment=include_already_has_shipment,
|
||||
merchant=printing_order.merchant,
|
||||
)
|
||||
|
||||
# 序列化返回
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from basic_info.services import EmployeeUserProvisioningService
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -72,50 +73,20 @@ class QuickCreateEmployeeUserView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
merchant = basic_models.Merchant.objects.get(id=data['merchant_id'])
|
||||
display_name = data['display_name']
|
||||
status_value = data.get('status') or basic_models.EmployeeStatusEnum.ACTIVE
|
||||
role_id = data.get('role_id')
|
||||
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
username=data['username'],
|
||||
password=data['password'],
|
||||
email=data.get('email') or '',
|
||||
)
|
||||
user.first_name = display_name
|
||||
user.save(update_fields=['first_name'])
|
||||
|
||||
# 如果指定了角色,将用户添加到对应的 Group
|
||||
if role_id:
|
||||
group = Group.objects.get(id=role_id)
|
||||
user.groups.add(group)
|
||||
|
||||
employee = basic_models.Employee.objects.create(
|
||||
merchant=merchant,
|
||||
sys_user=user,
|
||||
name=display_name,
|
||||
mobile=data.get('mobile') or '',
|
||||
area=data.get('area') or '',
|
||||
description=data.get('description') or '',
|
||||
status=status_value,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'display_name': display_name,
|
||||
'role_id': role_id,
|
||||
},
|
||||
'employee': {
|
||||
'id': employee.id,
|
||||
'name': employee.name,
|
||||
'status': employee.status,
|
||||
'merchant': merchant.id,
|
||||
},
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
result = EmployeeUserProvisioningService.quick_create_employee_user(
|
||||
username=data["username"],
|
||||
password=data["password"],
|
||||
display_name=data["display_name"],
|
||||
merchant_id=data["merchant_id"],
|
||||
email=data.get("email"),
|
||||
mobile=data.get("mobile"),
|
||||
area=data.get("area"),
|
||||
description=data.get("description"),
|
||||
status=data.get("status"),
|
||||
role_id=data.get("role_id"),
|
||||
)
|
||||
|
||||
return Response(
|
||||
result.to_dict(),
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
74
basic_info/management/commands/quick_create_employee_user.py
Normal file
74
basic_info/management/commands/quick_create_employee_user.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from basic_info.services import EmployeeUserProvisioningService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "快速创建系统用户并绑定 Employee,适合人工脚本或 MCP 调用。"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--username", required=True, help="用户名")
|
||||
parser.add_argument("--password", required=True, help="密码")
|
||||
parser.add_argument("--display-name", required=True, help="员工显示名称")
|
||||
parser.add_argument("--merchant-id", type=int, required=True, help="商户 ID")
|
||||
parser.add_argument("--email", default="", help="邮箱(可选)")
|
||||
parser.add_argument("--mobile", default="", help="手机号(可选)")
|
||||
parser.add_argument("--area", default="", help="地区(可选)")
|
||||
parser.add_argument("--description", default="", help="描述(可选)")
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
default=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
help="员工状态枚举文本,默认 在职",
|
||||
)
|
||||
parser.add_argument("--role-id", type=int, default=None, help="角色 ID(可选)")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="以 JSON 输出结果,适合脚本或 MCP 调用",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
result = EmployeeUserProvisioningService.quick_create_employee_user(
|
||||
username=options["username"],
|
||||
password=options["password"],
|
||||
display_name=options["display_name"],
|
||||
merchant_id=options["merchant_id"],
|
||||
email=options["email"],
|
||||
mobile=options["mobile"],
|
||||
area=options["area"],
|
||||
description=options["description"],
|
||||
status=options["status"],
|
||||
role_id=options["role_id"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
if options["json"]:
|
||||
self.stdout.write(
|
||||
json.dumps(
|
||||
{"ok": False, "error": str(exc)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
raise CommandError(str(exc))
|
||||
|
||||
payload = {
|
||||
"ok": True,
|
||||
"result": result.to_dict(),
|
||||
}
|
||||
if options["json"]:
|
||||
self.stdout.write(json.dumps(payload, ensure_ascii=False))
|
||||
return
|
||||
|
||||
user_payload = payload["result"]["user"]
|
||||
employee_payload = payload["result"]["employee"]
|
||||
self.stdout.write(self.style.SUCCESS("创建成功"))
|
||||
self.stdout.write(
|
||||
f"user: id={user_payload['id']} username={user_payload['username']} role_id={user_payload['role_id']}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"employee: id={employee_payload['id']} name={employee_payload['name']} merchant={employee_payload['merchant']} status={employee_payload['status']}"
|
||||
)
|
||||
@@ -1,6 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from . import models as basic_models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomerVisibilityService:
|
||||
@staticmethod
|
||||
@@ -79,3 +86,88 @@ class MerchantSettingService:
|
||||
@staticmethod
|
||||
def get_setting(merchant: basic_models.Merchant, key: basic_models.MerchantSettingKeyEnum):
|
||||
return basic_models.MerchantSetting.objects.get(merchant=merchant, key=key)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuickCreateEmployeeUserResult:
|
||||
user: object
|
||||
employee: basic_models.Employee
|
||||
role_id: int | None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"user": {
|
||||
"id": self.user.id,
|
||||
"username": self.user.username,
|
||||
"display_name": self.employee.name,
|
||||
"role_id": self.role_id,
|
||||
},
|
||||
"employee": {
|
||||
"id": self.employee.id,
|
||||
"name": self.employee.name,
|
||||
"status": self.employee.status,
|
||||
"merchant": self.employee.merchant_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class EmployeeUserProvisioningService:
|
||||
@staticmethod
|
||||
def quick_create_employee_user(
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
merchant_id: int,
|
||||
email: str | None = None,
|
||||
mobile: str | None = None,
|
||||
area: str | None = None,
|
||||
description: str | None = None,
|
||||
status: int | None = None,
|
||||
role_id: int | None = None,
|
||||
) -> QuickCreateEmployeeUserResult:
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
raise ValueError("用户名不能为空")
|
||||
if User.objects.filter(username=username).exists():
|
||||
raise ValueError("用户名已存在")
|
||||
|
||||
merchant = basic_models.Merchant.objects.filter(id=merchant_id).first()
|
||||
if merchant is None:
|
||||
raise ValueError("商户不存在")
|
||||
|
||||
group = None
|
||||
if role_id is not None:
|
||||
group = Group.objects.filter(id=role_id).first()
|
||||
if group is None:
|
||||
raise ValueError("角色不存在")
|
||||
|
||||
status_value = status or basic_models.EmployeeStatusEnum.ACTIVE
|
||||
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
password=password,
|
||||
email=(email or "").strip(),
|
||||
)
|
||||
user.first_name = display_name
|
||||
user.save(update_fields=["first_name"])
|
||||
|
||||
if group is not None:
|
||||
user.groups.add(group)
|
||||
|
||||
employee = basic_models.Employee.objects.create(
|
||||
merchant=merchant,
|
||||
sys_user=user,
|
||||
name=display_name,
|
||||
mobile=(mobile or "").strip(),
|
||||
area=(area or "").strip(),
|
||||
description=(description or "").strip(),
|
||||
status=status_value,
|
||||
)
|
||||
|
||||
return QuickCreateEmployeeUserResult(
|
||||
user=user,
|
||||
employee=employee,
|
||||
role_id=group.id if group is not None else None,
|
||||
)
|
||||
|
||||
105
docs/quick_create_employee_user_command.md
Normal file
105
docs/quick_create_employee_user_command.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# quick_create_employee_user 命令说明
|
||||
|
||||
这个命令是对现有 `api_v2` 快速创用户接口的命令化封装,底层复用同一套共享 service。
|
||||
|
||||
相关入口:
|
||||
|
||||
- API:`POST /api/v2/users/quick-create/`
|
||||
- Command:`python manage.py quick_create_employee_user`
|
||||
|
||||
## 共享实现
|
||||
|
||||
共享业务逻辑在:
|
||||
|
||||
- [basic_info/services.py](/home/f/coding/flower/basic_info/services.py)
|
||||
|
||||
API 和 command 都调用:
|
||||
|
||||
- `EmployeeUserProvisioningService.quick_create_employee_user(...)`
|
||||
|
||||
这样可以避免:
|
||||
|
||||
- API 与命令校验规则不一致
|
||||
- 后续 MCP server 再复制一份逻辑
|
||||
|
||||
## 参数
|
||||
|
||||
必填参数:
|
||||
|
||||
- `--username`
|
||||
- `--password`
|
||||
- `--display-name`
|
||||
- `--merchant-id`
|
||||
|
||||
可选参数:
|
||||
|
||||
- `--email`
|
||||
- `--mobile`
|
||||
- `--area`
|
||||
- `--description`
|
||||
- `--status`
|
||||
- `--role-id`
|
||||
- `--json`
|
||||
|
||||
## 示例
|
||||
|
||||
普通输出:
|
||||
|
||||
```bash
|
||||
python manage.py quick_create_employee_user \
|
||||
--username demo_user \
|
||||
--password pass123456 \
|
||||
--display-name 演示员工 \
|
||||
--merchant-id 1
|
||||
```
|
||||
|
||||
JSON 输出:
|
||||
|
||||
```bash
|
||||
python manage.py quick_create_employee_user \
|
||||
--json \
|
||||
--username demo_user \
|
||||
--password pass123456 \
|
||||
--display-name 演示员工 \
|
||||
--merchant-id 1 \
|
||||
--mobile 13800000000
|
||||
```
|
||||
|
||||
成功时输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"user": {
|
||||
"id": 127,
|
||||
"username": "demo_user",
|
||||
"display_name": "演示员工",
|
||||
"role_id": null
|
||||
},
|
||||
"employee": {
|
||||
"id": 128,
|
||||
"name": "演示员工",
|
||||
"status": "在职",
|
||||
"merchant": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
失败时如果带 `--json`,输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "用户名已存在"
|
||||
}
|
||||
```
|
||||
|
||||
## 设计目的
|
||||
|
||||
这个命令主要是为了:
|
||||
|
||||
- 让运维/开发可以不经过 HTTP 直接创建用户
|
||||
- 给外部脚本或 MCP server 提供稳定入口
|
||||
- 保持和现有 `api_v2` 行为一致
|
||||
@@ -28,7 +28,7 @@
|
||||
| limit | int | 分页大小(LimitOffsetPagination) |
|
||||
| offset | int | 偏移量 |
|
||||
| customer | int | 客户ID |
|
||||
| status | int | 状态(1=待送货, 2=已交付, 3=已取消) |
|
||||
| status | int | 状态(1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核) |
|
||||
| external_id | string | 外部订单号(精确匹配) |
|
||||
| shipment_date_from | string | 出货日期起始(YYYY-MM-DD) |
|
||||
| shipment_date_to | string | 出货日期结束(YYYY-MM-DD) |
|
||||
@@ -50,6 +50,13 @@
|
||||
"customer": 1,
|
||||
"customer_name": "客户A",
|
||||
"shipment_date": "2026-01-14",
|
||||
"status": 1,
|
||||
"status_display": "草稿(未发布)",
|
||||
"status_modified_at": null,
|
||||
"cancelled_by_id": null,
|
||||
"cancelled_by_name": null,
|
||||
"approved_by_id": null,
|
||||
"approved_by_name": null,
|
||||
"remark": "备注信息",
|
||||
"items_count": 3,
|
||||
"sales_items": [],
|
||||
@@ -69,12 +76,25 @@
|
||||
- **Method**: `GET`
|
||||
- **认证**: 需要登录(JWT Token)
|
||||
|
||||
#### 查询参数(可选)
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| status | int | 可选状态过滤;取值为 1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核。传入后仅当该出货单状态匹配时才返回详情 |
|
||||
|
||||
---
|
||||
|
||||
## 创建出货单
|
||||
|
||||
创建出货单并关联销售品。
|
||||
|
||||
业务规则补充:
|
||||
|
||||
- 当 `sales_items` 非空时,所有销售品都必须关联有效的 `printing_job`
|
||||
- 并且这些销售品必须全部来自同一个 `printing_order`
|
||||
- 如果存在缺少生产任务、生产任务不存在、或跨生产订单混装,接口会拒绝创建
|
||||
- 新创建的出货单默认状态为 `草稿(未发布)`
|
||||
|
||||
### 接口信息
|
||||
|
||||
- **URL**: `/api/v1/shipment/shipments/`
|
||||
@@ -115,6 +135,13 @@
|
||||
"shipment_date": "2026-01-14",
|
||||
"area": "华东",
|
||||
"remark": "备注信息",
|
||||
"status": 1,
|
||||
"status_display": "草稿(未发布)",
|
||||
"status_modified_at": null,
|
||||
"cancelled_by_id": null,
|
||||
"cancelled_by_name": null,
|
||||
"approved_by_id": null,
|
||||
"approved_by_name": null,
|
||||
"items_count": 3,
|
||||
"sales_items": [],
|
||||
"external_finished_products": [],
|
||||
@@ -137,12 +164,29 @@
|
||||
| shipment_date | string | 出货日期 |
|
||||
| area | string | 出货地区 |
|
||||
| remark | string | 备注 |
|
||||
| status | int | 状态枚举(1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核) |
|
||||
| status_display | string | 状态显示名称 |
|
||||
| status_modified_at | string/null | 最后一次状态修改时间 |
|
||||
| cancelled_by_id | int/null | 取消人 ID,仅当进入已取消时可能有值 |
|
||||
| cancelled_by_name | string/null | 取消人名称,仅当进入已取消时可能有值 |
|
||||
| approved_by_id | int/null | 审核人 ID,仅当进入已审核时可能有值 |
|
||||
| approved_by_name | string/null | 审核人名称,仅当进入已审核时可能有值 |
|
||||
| items_count | int | 关联的销售品数量 |
|
||||
| created_by_id | int | 创建人ID |
|
||||
| created_by_name | string | 创建人名称 |
|
||||
| created_at | string | 创建时间 |
|
||||
| updated_at | string | 更新时间 |
|
||||
|
||||
### 状态流转规则
|
||||
|
||||
- `草稿(未发布)` 只能流转到 `已发布`
|
||||
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
|
||||
- `已驳回` 可以流转到 `已审核` / `已取消`
|
||||
- `已审核` 可以流转到 `已取消`
|
||||
- `已取消` 不可再流转到其他状态
|
||||
- 重复设置同一状态保持幂等,不报错
|
||||
- 进入 `已审核` 时必须提供审核人
|
||||
|
||||
### 错误响应
|
||||
|
||||
#### 400 Bad Request - 客户不存在
|
||||
@@ -169,6 +213,30 @@
|
||||
}
|
||||
```
|
||||
|
||||
#### 400 Bad Request - 销售品缺少关联生产任务
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "以下销售品缺少关联生产任务,无法创建出货单: [1]"
|
||||
}
|
||||
```
|
||||
|
||||
#### 400 Bad Request - 销售品关联的生产任务不存在
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "以下销售品关联的生产任务不存在,无法创建出货单: [1]"
|
||||
}
|
||||
```
|
||||
|
||||
#### 400 Bad Request - 销售品来自不同生产订单
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "出货单中的销售品必须来自同一个生产订单"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 创建出货单(external 版)
|
||||
@@ -229,11 +297,13 @@
|
||||
"area": "华南",
|
||||
"remark": "external 备注(可选)",
|
||||
"status": 1,
|
||||
"status_display": "待送货",
|
||||
"status_display": "草稿(未发布)",
|
||||
"external_id": "EXT-ORDER-001",
|
||||
"cancelled_at": null,
|
||||
"status_modified_at": null,
|
||||
"cancelled_by_id": null,
|
||||
"cancelled_by_name": null,
|
||||
"approved_by_id": null,
|
||||
"approved_by_name": null,
|
||||
"items_count": 0,
|
||||
"external_finished_products_count": 2,
|
||||
"created_by_id": 1,
|
||||
|
||||
26
shipment/migrations/0011_alter_shipment_status_choices.py
Normal file
26
shipment/migrations/0011_alter_shipment_status_choices.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shipment", "0010_salesitem_merchant_shipment_area_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="shipment",
|
||||
name="status",
|
||||
field=models.IntegerField(
|
||||
choices=[
|
||||
(1, "草稿(未发布)"),
|
||||
(2, "已发布"),
|
||||
(3, "已取消"),
|
||||
(4, "已驳回"),
|
||||
(5, "已审核"),
|
||||
],
|
||||
default=1,
|
||||
verbose_name="状态",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shipment", "0011_alter_shipment_status_choices"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="shipment",
|
||||
name="cancelled_at",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="approved_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="approved_shipments",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="审核人",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="status_modified_at",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="状态修改时间"),
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from flower.common import ModelBase
|
||||
from basic_info import models as basic_models
|
||||
|
||||
@@ -23,9 +22,11 @@ class UnitChoices(models.IntegerChoices):
|
||||
|
||||
class ShipmentStatus(models.IntegerChoices):
|
||||
"""出货单状态"""
|
||||
PENDING_DELIVERY = 1, '待送货'
|
||||
DELIVERED = 2, '已交付'
|
||||
DRAFT = 1, '草稿(未发布)'
|
||||
PUBLISHED = 2, '已发布'
|
||||
CANCELLED = 3, '已取消'
|
||||
REJECTED = 4, '已驳回'
|
||||
APPROVED = 5, '已审核'
|
||||
|
||||
|
||||
class ExternalFinishedProduct(ModelBase):
|
||||
@@ -82,14 +83,14 @@ class Shipment(ModelBase):
|
||||
"""
|
||||
status = models.IntegerField(
|
||||
choices=ShipmentStatus.choices,
|
||||
default=ShipmentStatus.PENDING_DELIVERY,
|
||||
default=ShipmentStatus.DRAFT,
|
||||
verbose_name='状态',
|
||||
)
|
||||
|
||||
cancelled_at = models.DateTimeField(
|
||||
status_modified_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='取消时间',
|
||||
verbose_name='状态修改时间',
|
||||
)
|
||||
|
||||
cancelled_by = models.ForeignKey(
|
||||
@@ -101,6 +102,15 @@ class Shipment(ModelBase):
|
||||
verbose_name='取消人',
|
||||
)
|
||||
|
||||
approved_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='approved_shipments',
|
||||
verbose_name='审核人',
|
||||
)
|
||||
|
||||
external_id = models.CharField(
|
||||
max_length=120,
|
||||
null=True,
|
||||
@@ -161,15 +171,18 @@ class Shipment(ModelBase):
|
||||
|
||||
def cancel(self, operator: User | None) -> None:
|
||||
"""
|
||||
取消出货单(记录取消时间与操作者)
|
||||
取消出货单(委托给 service 处理状态机与时间记录)
|
||||
|
||||
Args:
|
||||
operator: 操作者(通常为 request.user,可为空)
|
||||
"""
|
||||
self.status = ShipmentStatus.CANCELLED
|
||||
self.cancelled_at = timezone.now()
|
||||
self.cancelled_by = operator
|
||||
self.save(update_fields=['status', 'cancelled_at', 'cancelled_by', 'updated_at'])
|
||||
from shipment.services import modify_status
|
||||
|
||||
modify_status(
|
||||
self,
|
||||
target_status=ShipmentStatus.CANCELLED,
|
||||
operator=operator,
|
||||
)
|
||||
|
||||
|
||||
class SalesItem(ModelBase):
|
||||
|
||||
@@ -9,8 +9,9 @@ from typing import List
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment
|
||||
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment, ShipmentStatus
|
||||
|
||||
|
||||
def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
|
||||
@@ -48,24 +49,50 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
|
||||
|
||||
|
||||
def get_sales_items_by_printing_order(
|
||||
printing_order_id: int,
|
||||
printing_order_id: int | str,
|
||||
include_already_has_shipment: bool = False,
|
||||
merchant=None,
|
||||
) -> QuerySet[SalesItem]:
|
||||
"""
|
||||
通过生产订单ID查询对应的销售品
|
||||
通过生产订单 ID 或 external_order_id 查询对应的销售品。
|
||||
|
||||
Args:
|
||||
printing_order_id: 生产订单ID
|
||||
printing_order_id: 生产订单内部 ID,或 external_order_id
|
||||
include_already_has_shipment: 是否包含已关联出货单的销售品,默认为 False
|
||||
merchant: 可选的商户约束;传入后会限定只在该商户下解析生产订单
|
||||
|
||||
Returns:
|
||||
SalesItem 查询集
|
||||
"""
|
||||
from printing.models import PrintingJob
|
||||
from printing.models import PrintingJob, PrintingOrder
|
||||
|
||||
# 1. 获取该生产订单下所有 PrintingJob 的 ID
|
||||
raw_value = str(printing_order_id).strip()
|
||||
order_queryset = PrintingOrder.objects.all()
|
||||
if merchant is not None:
|
||||
order_queryset = order_queryset.filter(merchant=merchant)
|
||||
|
||||
resolved_order_ids: list[int] = []
|
||||
|
||||
# 优先按内部 ID 解析,保持和当前 API 路径语义一致。
|
||||
if raw_value.isdigit():
|
||||
resolved_order_ids = list(
|
||||
order_queryset.filter(id=int(raw_value)).values_list("id", flat=True)[:1]
|
||||
)
|
||||
|
||||
# 内部 ID 未命中时,再按 external_order_id 查询。
|
||||
if not resolved_order_ids and raw_value:
|
||||
resolved_order_ids = list(
|
||||
order_queryset.filter(external_order_id=raw_value).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
if not resolved_order_ids:
|
||||
return SalesItem.objects.none().select_related("shipment").order_by("id")
|
||||
|
||||
# 1. 获取目标生产订单下所有 PrintingJob 的 ID
|
||||
job_ids = PrintingJob.objects.filter(
|
||||
printing_order_id=printing_order_id
|
||||
printing_order_id__in=resolved_order_ids
|
||||
).values_list("id", flat=True)
|
||||
|
||||
# 2. 查询 SalesItem,过滤 printing_job_id 在这些 job_ids 中
|
||||
@@ -110,6 +137,76 @@ def get_sales_items_by_customer(
|
||||
return queryset.select_related("shipment").order_by("id")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def modify_status(
|
||||
shipment: Shipment,
|
||||
*,
|
||||
target_status: int,
|
||||
operator=None,
|
||||
approved_by=None,
|
||||
) -> Shipment:
|
||||
"""
|
||||
修改出货单状态,并记录状态修改时间。
|
||||
|
||||
规则:
|
||||
- 草稿 -> 只能已发布
|
||||
- 已发布 -> 已审核 / 已驳回 / 已取消
|
||||
- 已驳回 -> 已审核 / 已取消
|
||||
- 已审核 -> 已取消
|
||||
- 已取消 -> 不可再变
|
||||
- 重复设置同一状态保持幂等,直接返回
|
||||
"""
|
||||
current_status = shipment.status
|
||||
if current_status == target_status:
|
||||
return shipment
|
||||
|
||||
allowed_transitions = {
|
||||
ShipmentStatus.DRAFT: {ShipmentStatus.PUBLISHED},
|
||||
ShipmentStatus.PUBLISHED: {
|
||||
ShipmentStatus.APPROVED,
|
||||
ShipmentStatus.REJECTED,
|
||||
ShipmentStatus.CANCELLED,
|
||||
},
|
||||
ShipmentStatus.REJECTED: {
|
||||
ShipmentStatus.APPROVED,
|
||||
ShipmentStatus.CANCELLED,
|
||||
},
|
||||
ShipmentStatus.APPROVED: {
|
||||
ShipmentStatus.CANCELLED,
|
||||
},
|
||||
ShipmentStatus.CANCELLED: set(),
|
||||
}
|
||||
|
||||
if target_status not in allowed_transitions.get(current_status, set()):
|
||||
raise ValueError(
|
||||
f"不允许将出货单状态从 {shipment.get_status_display()} 修改为 "
|
||||
f"{ShipmentStatus(target_status).label}"
|
||||
)
|
||||
|
||||
if target_status == ShipmentStatus.APPROVED and approved_by is None:
|
||||
raise ValueError("目标状态为已审核时,approved_by 不能为空")
|
||||
|
||||
shipment.status = target_status
|
||||
shipment.status_modified_at = timezone.now()
|
||||
|
||||
if target_status == ShipmentStatus.CANCELLED:
|
||||
shipment.cancelled_by = operator
|
||||
|
||||
if target_status == ShipmentStatus.APPROVED:
|
||||
shipment.approved_by = approved_by
|
||||
|
||||
shipment.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"status_modified_at",
|
||||
"cancelled_by",
|
||||
"approved_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return shipment
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_shipment(
|
||||
customer_id: int,
|
||||
@@ -154,6 +251,8 @@ def create_shipment(
|
||||
|
||||
# 验证销售品
|
||||
if sales_item_ids:
|
||||
from printing.models import PrintingJob
|
||||
|
||||
# 查询销售品
|
||||
sales_items = SalesItem.objects.filter(id__in=sales_item_ids)
|
||||
found_ids = set(sales_items.values_list("id", flat=True))
|
||||
@@ -168,6 +267,40 @@ def create_shipment(
|
||||
shipped_ids = list(already_shipped.values_list("id", flat=True))
|
||||
raise ValueError(f"以下销售品已关联到其他出货单: {shipped_ids}")
|
||||
|
||||
sales_items_list = list(sales_items)
|
||||
missing_printing_job_ids = [
|
||||
item.id for item in sales_items_list if not item.printing_job_id
|
||||
]
|
||||
if missing_printing_job_ids:
|
||||
raise ValueError(
|
||||
f"以下销售品缺少关联生产任务,无法创建出货单: {missing_printing_job_ids}"
|
||||
)
|
||||
|
||||
printing_job_ids = {item.printing_job_id for item in sales_items_list}
|
||||
printing_job_map = {
|
||||
job.id: job.printing_order_id
|
||||
for job in PrintingJob.objects.filter(id__in=printing_job_ids).only(
|
||||
"id", "printing_order_id"
|
||||
)
|
||||
}
|
||||
|
||||
invalid_printing_job_ids = sorted(printing_job_ids - set(printing_job_map.keys()))
|
||||
if invalid_printing_job_ids:
|
||||
affected_item_ids = sorted(
|
||||
item.id
|
||||
for item in sales_items_list
|
||||
if item.printing_job_id in invalid_printing_job_ids
|
||||
)
|
||||
raise ValueError(
|
||||
f"以下销售品关联的生产任务不存在,无法创建出货单: {affected_item_ids}"
|
||||
)
|
||||
|
||||
printing_order_ids = {
|
||||
printing_job_map[item.printing_job_id] for item in sales_items_list
|
||||
}
|
||||
if len(printing_order_ids) > 1:
|
||||
raise ValueError("出货单中的销售品必须来自同一个生产订单")
|
||||
|
||||
# 创建出货单
|
||||
shipment = Shipment.objects.create(
|
||||
merchant=merchant,
|
||||
|
||||
Reference in New Issue
Block a user