From 1be840a8c2645ca89762cd12eff2b0c1b1372dbe Mon Sep 17 00:00:00 2001 From: colaftc Date: Thu, 2 Apr 2026 18:07:06 +0800 Subject: [PATCH] feat: shipment patch --- .codex | 0 .vscode/mcp.json | 8 + api_v1/views/shipment/serializers.py | 16 +- api_v1/views/shipment/test_api.py | 359 ++++++++++++++++++ api_v1/views/shipment/views.py | 13 +- api_v2/views/users.py | 65 +--- .../commands/quick_create_employee_user.py | 74 ++++ basic_info/services.py | 92 +++++ docs/quick_create_employee_user_command.md | 105 +++++ docs/shipment_api.md | 76 +++- .../0011_alter_shipment_status_choices.py | 26 ++ ..._status_modified_at_and_add_approved_by.py | 35 ++ shipment/models.py | 35 +- shipment/services.py | 147 ++++++- 14 files changed, 979 insertions(+), 72 deletions(-) create mode 100644 .codex create mode 100644 .vscode/mcp.json create mode 100644 basic_info/management/commands/quick_create_employee_user.py create mode 100644 docs/quick_create_employee_user_command.md create mode 100644 shipment/migrations/0011_alter_shipment_status_choices.py create mode 100644 shipment/migrations/0012_replace_cancelled_at_with_status_modified_at_and_add_approved_by.py diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..247bf3d --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "flower-erp-users-http": { + "type": "http", + "url": "http://127.0.0.1:8099/mcp" + } + } +} diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 057dda2..1778919 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -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): """ 出货单关联的销售品明细(无则返回空数组)。 diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 304d95f..53df44d 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -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 测试""" diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index d614ebd..de79f0d 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -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, ) # 序列化返回 diff --git a/api_v2/views/users.py b/api_v2/views/users.py index c44bddb..3d1d4e0 100644 --- a/api_v2/views/users.py +++ b/api_v2/views/users.py @@ -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, + ) diff --git a/basic_info/management/commands/quick_create_employee_user.py b/basic_info/management/commands/quick_create_employee_user.py new file mode 100644 index 0000000..0eececc --- /dev/null +++ b/basic_info/management/commands/quick_create_employee_user.py @@ -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']}" + ) diff --git a/basic_info/services.py b/basic_info/services.py index 35b953d..f71dad6 100644 --- a/basic_info/services.py +++ b/basic_info/services.py @@ -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, + ) diff --git a/docs/quick_create_employee_user_command.md b/docs/quick_create_employee_user_command.md new file mode 100644 index 0000000..5733eb4 --- /dev/null +++ b/docs/quick_create_employee_user_command.md @@ -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` 行为一致 diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 22fde67..9bf7138 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -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, diff --git a/shipment/migrations/0011_alter_shipment_status_choices.py b/shipment/migrations/0011_alter_shipment_status_choices.py new file mode 100644 index 0000000..d726997 --- /dev/null +++ b/shipment/migrations/0011_alter_shipment_status_choices.py @@ -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="状态", + ), + ), + ] diff --git a/shipment/migrations/0012_replace_cancelled_at_with_status_modified_at_and_add_approved_by.py b/shipment/migrations/0012_replace_cancelled_at_with_status_modified_at_and_add_approved_by.py new file mode 100644 index 0000000..50b498a --- /dev/null +++ b/shipment/migrations/0012_replace_cancelled_at_with_status_modified_at_and_add_approved_by.py @@ -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="状态修改时间"), + ), + ] diff --git a/shipment/models.py b/shipment/models.py index fcc2256..1b9c520 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -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): diff --git a/shipment/services.py b/shipment/services.py index 781a2f6..02bae6c 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -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,