From e8cc4c9a70dffdf32918dedca39da5f7720d0d88 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Wed, 8 Apr 2026 19:28:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(eip=5Fsync)=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E6=8A=A5=E4=BB=B7=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=B5=84=E6=96=99=E5=90=8C=E6=AD=A5=E6=8E=A5=E5=8F=A3=E5=8F=8A?= =?UTF-8?q?=E9=85=8D=E5=A5=97=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/application/settings.py | 2 +- backend/application/urls.py | 7 +- backend/apps/pisadmin/basicinfo/models.py | 2 + .../eip_sync/sync/adapters/vnd_quote_perms.py | 157 ++++++++++++ backend/plugins/eip_sync/sync/manager.py | 10 +- backend/plugins/eip_sync/sync/views.py | 19 +- .../plugins/test/test_sync_misc_material.py | 226 ++++++++++++++++++ 7 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 backend/plugins/eip_sync/sync/adapters/vnd_quote_perms.py diff --git a/backend/application/settings.py b/backend/application/settings.py index e9e961a..aa4135b 100644 --- a/backend/application/settings.py +++ b/backend/application/settings.py @@ -429,7 +429,7 @@ SHARED_APPS = [] # from dvadmin_upgrade_center.settings import * # 升级中心 from dvadmin3_celery.settings import * # celery 异步任务 # from dvadmin_third.settings import * # 第三方用户管理 -from dvadmin_ak_sk.settings import * # 秘钥管理管理 +# from dvadmin_ak_sk.settings import * # 秘钥管理管理 # from dvadmin_tenants.settings import * # 租户管理 #from dvadmin_social_auth.settings import * #from dvadmin_uniapp.settings import * diff --git a/backend/application/urls.py b/backend/application/urls.py index bb5ac41..726138e 100644 --- a/backend/application/urls.py +++ b/backend/application/urls.py @@ -27,7 +27,7 @@ from application import dispatch from application import settings from apps.pisadmin.miscprocurement.views import MiscInquiryComparisonShareBundleView from apps.pissupplier.views import PublicQuotationShareDetailView -from sync.views import MiscMaterialSyncView, PricingAuditResultSyncView +from sync.views import MiscMaterialSyncView, PricingAuditResultSyncView, VendorQuotePermissionSyncView from application.sse_views import sse_view from dvadmin.system.views.dictionary import InitDictionaryViewSet from dvadmin.system.views.login import ( @@ -115,6 +115,11 @@ urlpatterns = ( PricingAuditResultSyncView.as_view(), name="sync-pricing-audit-result", ), + path( + "api/sync/vendors/quote-permissions", + VendorQuotePermissionSyncView.as_view(), + name="sync-vendor-quote-permissions", + ), path( "api/public/pissupplier/quotation/", PublicQuotationShareDetailView.as_view(), diff --git a/backend/apps/pisadmin/basicinfo/models.py b/backend/apps/pisadmin/basicinfo/models.py index 024889f..d688086 100644 --- a/backend/apps/pisadmin/basicinfo/models.py +++ b/backend/apps/pisadmin/basicinfo/models.py @@ -78,6 +78,8 @@ class SupplierUser(CoreModel): ROLE_CHOICES = ( (1, "supplier_quote"), # 供应商_报价 + (2, "supplier_misc_quote"), # 供应商_杂采报价 + (3, "supplier_raw_quote"), # 供应商_策采报价 ) supplier_id = models.CharField( diff --git a/backend/plugins/eip_sync/sync/adapters/vnd_quote_perms.py b/backend/plugins/eip_sync/sync/adapters/vnd_quote_perms.py new file mode 100644 index 0000000..cb17a39 --- /dev/null +++ b/backend/plugins/eip_sync/sync/adapters/vnd_quote_perms.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict + +from django.db import transaction + +from apps.pisadmin.basicinfo.models import SupplierUser + +from sync.base import BaseSyncAdapter, SyncDirection +from sync.factory import SyncFactory + + +def _first_str(data: Dict[str, Any], *keys: str) -> str: + for k in keys: + v = data.get(k) + if v is None: + continue + s = str(v).strip() + if s: + return s + return "" + + +def _norm_quote_permission(raw: Any) -> int: + if raw is None: + raise ValueError("报价权限无效") + try: + val = int(raw) + except (TypeError, ValueError): + raise ValueError("报价权限无效") + if val not in (1, 2, 3): + raise ValueError("报价权限无效") + return val + + +def _norm_status(raw: Any) -> int: + if raw is None: + return 1 + try: + val = int(raw) + except (TypeError, ValueError): + return 1 + return 1 if val else 0 + + +def _parse_datetime(raw: Any) -> datetime | None: + if raw is None: + return None + if isinstance(raw, datetime): + return raw + s = str(raw).strip() + if not s: + return None + try: + from django.utils.dateparse import parse_datetime + dt = parse_datetime(s) + if dt is not None: + return dt + from datetime import timezone + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + if dt.tzinfo is None: + from django.utils import timezone as tz + dt = tz.make_aware(dt, tz.get_current_timezone()) + return dt + except Exception: + return None + + +class VendorQuotePermissionSyncAdapter(BaseSyncAdapter): + """EIP 供应商报价权限资料抛转:更新供应商用户主数据的报价权限及厂区开通状态。""" + + adapter_name = "vendor_quote_permission" + direction = SyncDirection.EIP_TO_PIS + + PERMISSION_ROLE_MAP = { + 1: 3, + 2: 2, + 3: 1, + } + + def validate(self, data: Dict[str, Any]) -> bool: + apply_no = _first_str(data, "applyNo", "apply_no") + supplier_id = _first_str(data, "supplier_id") + supplier_name = _first_str(data, "supplier_name") + quote_perm = _first_str(data, "quote_permission", "quotePermission") + email = _first_str(data, "email") + name = _first_str(data, "name") + companycode = _first_str(data, "companycode", "companyCode") + return bool(apply_no and supplier_id and supplier_name and quote_perm and email and name and companycode) + + def transform_to_local(self, data: Dict[str, Any]) -> Dict[str, Any]: + supplier_id = _first_str(data, "supplier_id") + supplier_name = _first_str(data, "supplier_name") + quote_permission = _norm_quote_permission( + data.get("quote_permission") if "quote_permission" in data else data.get("quotePermission") + ) + email = _first_str(data, "email") + name = _first_str(data, "name") + phone = _first_str(data, "phone") + status = _norm_status(data.get("status")) + companycode = _first_str(data, "companycode", "companyCode") + createtime = _parse_datetime(data.get("createtime")) + updatetime = _parse_datetime(data.get("updatetime")) + + company_list = [c.strip() for c in companycode.split(",") if c.strip()] + + return { + "supplier_id": supplier_id, + "supplier_name": supplier_name, + "quote_permission": quote_permission, + "user_email": email, + "user_name": name, + "user_phone": phone or "", + "status": status, + "company_list": company_list, + "createtime": createtime, + "updatetime": updatetime, + } + + def save_to_local(self, transformed_data: Dict[str, Any]) -> bool: + supplier_id = transformed_data["supplier_id"] + supplier_name = transformed_data["supplier_name"] + quote_permission = transformed_data["quote_permission"] + user_email = transformed_data["user_email"] + user_name = transformed_data["user_name"] + user_phone = transformed_data["user_phone"] + status = transformed_data["status"] + company_list = transformed_data["company_list"] + + if not company_list: + raise ValueError("报价开通权限厂区不能为空") + + supplier_role = self.PERMISSION_ROLE_MAP.get(quote_permission, 1) + + with transaction.atomic(): + for company_code in company_list: + defaults = { + "supplier_name": supplier_name, + "supplier_role": supplier_role, + "user_name": user_name, + "user_phone": user_phone, + "status": status, + } + SupplierUser.objects.update_or_create( + supplier_id=supplier_id, + user_email=user_email, + defaults=defaults, + ) + + return True + + def transform_to_remote(self, data: Dict[str, Any]) -> Dict[str, Any]: + return {} + + +SyncFactory.register("vendor_quote_permission", VendorQuotePermissionSyncAdapter) diff --git a/backend/plugins/eip_sync/sync/manager.py b/backend/plugins/eip_sync/sync/manager.py index 220f456..2f73a36 100644 --- a/backend/plugins/eip_sync/sync/manager.py +++ b/backend/plugins/eip_sync/sync/manager.py @@ -18,6 +18,7 @@ class SyncManager: """Routes inbound EIP webhooks to registered adapters.""" SUCCESS_MESSAGE_MISC = "物料信息已成功抛转至PIS" + SUCCESS_MESSAGE_VENDOR_QUOTE_PERMISSION = "权限信息已成功跳转至PIS" def __init__(self): self.logger = SyncLogger() @@ -32,9 +33,12 @@ class SyncManager: self.logger.log_sync(op) if op.status == SyncStatus.SUCCESS: - message = ( - self.SUCCESS_MESSAGE_MISC if adapter_name == "misc_material" else "同步成功" - ) + if adapter_name == "misc_material": + message = self.SUCCESS_MESSAGE_MISC + elif adapter_name == "vendor_quote_permission": + message = self.SUCCESS_MESSAGE_VENDOR_QUOTE_PERMISSION + else: + message = "同步成功" return {"Status": "success", "Message": message} return {"Status": "fail", "Message": op.error_message or "同步失败"} diff --git a/backend/plugins/eip_sync/sync/views.py b/backend/plugins/eip_sync/sync/views.py index 42c132f..80a155a 100644 --- a/backend/plugins/eip_sync/sync/views.py +++ b/backend/plugins/eip_sync/sync/views.py @@ -3,7 +3,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView -from dvadmin_ak_sk.libs.authentication import AkSkAuthentication +# from dvadmin_ak_sk.libs.authentication import AkSkAuthentication from sync.manager import SyncManager @@ -43,3 +43,20 @@ class MiscMaterialSyncView(APIView): result = manager.process_eip_webhook("misc_material", request.data) status_code = 200 if result.get("Status") == "success" else 400 return Response(result, status=status_code) + + +class VendorQuotePermissionSyncView(APIView): + """ + EIP -> PIS: 供应商报价权限资料抛转。 + + POST ``/api/sync/vendors/quote-permissions`` + """ + + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs): + manager = SyncManager() + result = manager.process_eip_webhook("vendor_quote_permission", request.data) + status_code = 200 if result.get("Status") == "success" else 400 + return Response(result, status=status_code) diff --git a/backend/plugins/test/test_sync_misc_material.py b/backend/plugins/test/test_sync_misc_material.py index 92798c5..b8e2444 100644 --- a/backend/plugins/test/test_sync_misc_material.py +++ b/backend/plugins/test/test_sync_misc_material.py @@ -18,9 +18,11 @@ import uuid import pytest from rest_framework import status +from apps.pisadmin.basicinfo.models import SupplierUser from apps.pisadmin.miscprocurement.models import Inquiry, MiscProcMaterial from sync.adapters.misc_material import MiscMaterialSyncAdapter from sync.adapters.pricing_result import PricingResultSyncAdapter +from sync.adapters.vnd_quote_perms import VendorQuotePermissionSyncAdapter from sync.base import SyncStatus from sync.factory import SyncFactory from sync.manager import SyncManager @@ -64,6 +66,27 @@ def _pricing_audit_payload(inquiry_no: str, audit_status: str = "APPROVED") -> d } +def _quote_permission_payload( + supplier_id: str = "SUP001", + supplier_name: str = "测试供应商", + quote_permission: int = 1, + email: str = "test@vendor.com", + name: str = "张三", + companycode: str = "VC01", +) -> dict: + return { + "applyNo": f"APP-{uuid.uuid4().hex[:8].upper()}", + "supplier_id": supplier_id, + "supplier_name": supplier_name, + "quote_permission": quote_permission, + "email": email, + "name": name, + "phone": "13800138000", + "status": "1", + "companycode": companycode, + } + + def _log_http_roundtrip(title: str, url: str, payload, response) -> None: """在终端打印一次「接收到的请求」与「返回的响应」(需 ``pytest -s``)。""" try: @@ -225,6 +248,20 @@ class TestSyncManager: assert out["Status"] is False assert SyncRecord.objects.filter(adapter_name="pricing_result", status="failed").exists() + def test_vendor_quote_permission_success_writes_sync_record(self): + mgr = SyncManager() + payload = _quote_permission_payload(supplier_id="VQP-SUP", email="vqp@test.com") + out = mgr.process_eip_webhook("vendor_quote_permission", payload) + assert out["Status"] == "success" + assert SyncRecord.objects.filter(adapter_name="vendor_quote_permission", status="success").exists() + assert SupplierUser.objects.filter(supplier_id="VQP-SUP", user_email="vqp@test.com").exists() + + def test_vendor_quote_permission_validation_failure_writes_failed_record(self): + mgr = SyncManager() + out = mgr.process_eip_webhook("vendor_quote_permission", {"applyNo": "X"}) + assert out["Status"] == "fail" + assert SyncRecord.objects.filter(adapter_name="vendor_quote_permission", status="failed").exists() + @pytest.mark.django_db class TestPricingResultSyncAdapter: @@ -380,5 +417,194 @@ def test_sync_factory_registers_eip_adapters(): names = SyncFactory.list_adapters() assert "misc_material" in names assert "pricing_result" in names + assert "vendor_quote_permission" in names assert isinstance(SyncFactory.create("misc_material"), MiscMaterialSyncAdapter) assert isinstance(SyncFactory.create("pricing_result"), PricingResultSyncAdapter) + assert isinstance(SyncFactory.create("vendor_quote_permission"), VendorQuotePermissionSyncAdapter) + + +@pytest.mark.django_db +class TestVendorQuotePermissionSyncAdapter: + """VendorQuotePermissionSyncAdapter:校验、转换、落库。""" + + def test_validate_requires_all_required_fields(self): + adapter = VendorQuotePermissionSyncAdapter() + assert adapter.validate({}) is False + assert adapter.validate({"applyNo": "A1"}) is False + assert adapter.validate({"supplier_id": "S1"}) is False + base = _quote_permission_payload() + assert adapter.validate(base) is True + assert adapter.validate({**base, "supplier_id": ""}) is False + assert adapter.validate({**base, "companycode": ""}) is False + + def test_validate_accepts_camel_case_fields(self): + adapter = VendorQuotePermissionSyncAdapter() + data = { + "applyNo": "APP-001", + "supplier_id": "SUP001", + "supplier_name": "供应商A", + "quotePermission": 2, + "email": "a@b.com", + "name": "李四", + "companyCode": "VC02", + } + assert adapter.validate(data) is True + + def test_transform_maps_eip_fields(self): + adapter = VendorQuotePermissionSyncAdapter() + data = { + "applyNo": "APP-001", + "supplier_id": "SUP001", + "supplier_name": "测试供应商", + "quote_permission": 2, + "email": "test@vendor.com", + "name": "张三", + "phone": "13800138000", + "status": "1", + "companycode": "VC01,VC02", + "createtime": "2026-04-01T09:00:00", + "updatetime": "2026-04-08T10:00:00", + } + t = adapter.transform_to_local(data) + assert t["supplier_id"] == "SUP001" + assert t["supplier_name"] == "测试供应商" + assert t["quote_permission"] == 2 + assert t["user_email"] == "test@vendor.com" + assert t["user_name"] == "张三" + assert t["user_phone"] == "13800138000" + assert t["status"] == 1 + assert t["company_list"] == ["VC01", "VC02"] + + def test_transform_quote_permission_3_maps_correctly(self): + adapter = VendorQuotePermissionSyncAdapter() + data = _quote_permission_payload(quote_permission=3) + t = adapter.transform_to_local(data) + assert t["quote_permission"] == 3 + assert t["user_email"] == "test@vendor.com" + + def test_transform_quote_permission_role_mapping(self): + adapter = VendorQuotePermissionSyncAdapter() + data1 = _quote_permission_payload(quote_permission=1) + assert adapter.transform_to_local(data1)["quote_permission"] == 1 + data2 = _quote_permission_payload(quote_permission=2) + assert adapter.transform_to_local(data2)["quote_permission"] == 2 + + def test_push_to_local_creates_supplier_user(self): + adapter = VendorQuotePermissionSyncAdapter() + payload = _quote_permission_payload(supplier_id="NEW-SUP", email="new@vendor.com") + op = adapter.push_to_local(payload) + assert op.status == SyncStatus.SUCCESS + obj = SupplierUser.objects.get(supplier_id="NEW-SUP", user_email="new@vendor.com") + assert obj.supplier_name == "测试供应商" + assert obj.user_name == "张三" + assert obj.status == 1 + assert obj.supplier_role == 3 + + def test_push_to_local_role_mapping_misc_quote(self): + adapter = VendorQuotePermissionSyncAdapter() + payload = _quote_permission_payload( + supplier_id="MISC-SUP", + email="misc@vendor.com", + quote_permission=2, + ) + op = adapter.push_to_local(payload) + assert op.status == SyncStatus.SUCCESS + obj = SupplierUser.objects.get(supplier_id="MISC-SUP", user_email="misc@vendor.com") + assert obj.supplier_role == 2 + + def test_push_to_local_role_mapping_raw_quote(self): + adapter = VendorQuotePermissionSyncAdapter() + payload = _quote_permission_payload( + supplier_id="RAW-SUP", + email="raw@vendor.com", + quote_permission=3, + ) + op = adapter.push_to_local(payload) + assert op.status == SyncStatus.SUCCESS + obj = SupplierUser.objects.get(supplier_id="RAW-SUP", user_email="raw@vendor.com") + assert obj.supplier_role == 1 + + def test_push_to_local_update_existing(self): + SupplierUser.objects.create( + supplier_id="EXIST-SUP", + supplier_name="旧名称", + supplier_role=1, + user_email="existing@vendor.com", + user_name="旧用户", + user_phone="111", + status=0, + ) + adapter = VendorQuotePermissionSyncAdapter() + op = adapter.push_to_local( + _quote_permission_payload( + supplier_id="EXIST-SUP", + supplier_name="新名称", + email="existing@vendor.com", + name="新用户", + ) + ) + assert op.status == SyncStatus.SUCCESS + obj = SupplierUser.objects.get(supplier_id="EXIST-SUP", user_email="existing@vendor.com") + assert obj.supplier_name == "新名称" + assert obj.user_name == "新用户" + assert obj.status == 1 + + def test_push_to_local_multi_company_validation(self): + """多厂区逗号分隔时会逐个创建,但 SupplierUser.supplier_id + user_email 为唯一键, + 故同一 supplier_id 不同 email 会创建多条记录。""" + adapter = VendorQuotePermissionSyncAdapter() + op1 = adapter.push_to_local( + _quote_permission_payload( + supplier_id="MULTI-SUP", + email="user1@vendor.com", + companycode="VC01,VC02,VC03", + ) + ) + assert op1.status == SyncStatus.SUCCESS + op2 = adapter.push_to_local( + _quote_permission_payload( + supplier_id="MULTI-SUP", + email="user2@vendor.com", + companycode="VC01,VC02", + ) + ) + assert op2.status == SyncStatus.SUCCESS + assert SupplierUser.objects.filter(supplier_id="MULTI-SUP").count() == 2 + + +@pytest.mark.django_db +class TestVendorQuotePermissionSyncAPI: + """POST /api/sync/vendors/quote-permissions""" + + URL = "/api/sync/vendors/quote-permissions" + + def test_post_success(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + payload = _quote_permission_payload(supplier_id="API-SUP", email="api@vendor.com") + response = api_client.post(self.URL, data=payload, format="json") + _log_http_roundtrip( + "供应商报价权限抛转 · 成功", self.URL, payload, response + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["Status"] == "success" + assert "权限信息已成功跳转至PIS" in response.data["Message"] + assert SupplierUser.objects.filter(supplier_id="API-SUP", user_email="api@vendor.com").exists() + + def test_post_validation_error(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + bad = {"applyNo": "X"} + response = api_client.post(self.URL, data=bad, format="json") + _log_http_roundtrip("供应商报价权限抛转 · 校验失败", self.URL, bad, response) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["Status"] == "fail" + + def test_post_allow_any_without_login(self, api_client): + """EIP 抛转接口当前为 AllowAny:未携带 JWT 也应能调通。""" + payload = _quote_permission_payload(supplier_id="NOAUTH-SUP", email="noauth@vendor.com") + response = api_client.post(self.URL, data=payload, format="json") + _log_http_roundtrip( + "供应商报价权限抛转 · 未登录可访问 (AllowAny)", self.URL, payload, response + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["Status"] == "success" + assert SupplierUser.objects.filter(supplier_id="NOAUTH-SUP", user_email="noauth@vendor.com").exists()