1
0
forked from erp-dev/erp

feat: shipment patch

This commit is contained in:
2026-04-02 18:07:06 +08:00
parent d36d9c1d9c
commit 1be840a8c2
14 changed files with 979 additions and 72 deletions

0
.codex Normal file
View File

8
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"flower-erp-users-http": {
"type": "http",
"url": "http://127.0.0.1:8099/mcp"
}
}
}

View File

@@ -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):
"""
出货单关联的销售品明细(无则返回空数组)。

View File

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

View File

@@ -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,
)
# 序列化返回

View File

@@ -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,
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(
{
'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,
},
},
result.to_dict(),
status=status.HTTP_201_CREATED,
)

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

View File

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

View 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` 行为一致

View File

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

View 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="状态",
),
),
]

View File

@@ -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="状态修改时间"),
),
]

View File

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

View File

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