From 328781ed5f3d52bece6053d0d0604f0afa2c83d0 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Thu, 9 Apr 2026 11:37:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(pisadmin)=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=AD=96=E9=87=87=E6=96=99=E5=8F=B7=E4=BF=A1=E6=81=AF=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=8E=9F=E6=96=99=E6=96=99=E5=8F=B7?= =?UTF-8?q?=E6=8A=9B=E8=BD=AC=E6=8E=A5=E5=8F=A3=E4=B8=9A=E5=8A=A1=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/application/settings.py | 1 + backend/application/urls.py | 8 +- backend/apps/pisadmin/procurement/apps.py | 7 + backend/apps/pisadmin/procurement/models.py | 45 ++++ .../apps/pisadmin/procurement/serializers.py | 26 ++ backend/apps/pisadmin/procurement/urls.py | 9 + backend/apps/pisadmin/procurement/views.py | 16 ++ .../eip_sync/sync/adapters/raw_material.py | 140 +++++++++++ backend/plugins/eip_sync/sync/apps.py | 2 + backend/plugins/eip_sync/sync/manager.py | 3 + backend/plugins/eip_sync/sync/views.py | 17 ++ .../plugins/test/test_sync_misc_material.py | 232 ++++++++++++++++-- 12 files changed, 481 insertions(+), 25 deletions(-) create mode 100644 backend/apps/pisadmin/procurement/apps.py create mode 100644 backend/apps/pisadmin/procurement/models.py create mode 100644 backend/apps/pisadmin/procurement/serializers.py create mode 100644 backend/apps/pisadmin/procurement/urls.py create mode 100644 backend/apps/pisadmin/procurement/views.py create mode 100644 backend/plugins/eip_sync/sync/adapters/raw_material.py diff --git a/backend/application/settings.py b/backend/application/settings.py index aa4135b..a635010 100644 --- a/backend/application/settings.py +++ b/backend/application/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = [ "channels", "dvadmin.system", "apps.pisadmin.miscprocurement", + "apps.pisadmin.procurement", "apps.pisadmin.basicinfo", "apps.pisadmin.dashboard", "apps.pissupplier", diff --git a/backend/application/urls.py b/backend/application/urls.py index deb76ca..fec95e9 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, VendorQuotePermissionSyncView +from sync.views import MiscMaterialSyncView, PricingAuditResultSyncView, VendorQuotePermissionSyncView, RawMaterialSyncView from application.sse_views import sse_view from dvadmin.system.views.dictionary import InitDictionaryViewSet from dvadmin.system.views.login import ( @@ -120,6 +120,11 @@ urlpatterns = ( VendorQuotePermissionSyncView.as_view(), name="sync-vendor-quote-permissions", ), + path( + "api/sync/materials/raw", + RawMaterialSyncView.as_view(), + name="sync-raw-material", + ), path( "api/public/pissupplier/quotation/", PublicQuotationShareDetailView.as_view(), @@ -131,6 +136,7 @@ urlpatterns = ( name="public-misc-comparison-bundle", ), path("api/pisadmin/miscprocurement/", include("apps.pisadmin.miscprocurement.urls")), + path("api/pisadmin/procurement/", include("apps.pisadmin.procurement.urls")), path("api/pisadmin/basicinfo/", include("apps.pisadmin.basicinfo.urls")), path("api/pisadmin/dashboard/", include("apps.pisadmin.dashboard.urls")), path("api/pissupplier/", include("apps.pissupplier.urls")), diff --git a/backend/apps/pisadmin/procurement/apps.py b/backend/apps/pisadmin/procurement/apps.py new file mode 100644 index 0000000..db22624 --- /dev/null +++ b/backend/apps/pisadmin/procurement/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProcurementConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.pisadmin.procurement" + verbose_name = "策采管理" diff --git a/backend/apps/pisadmin/procurement/models.py b/backend/apps/pisadmin/procurement/models.py new file mode 100644 index 0000000..9f0d085 --- /dev/null +++ b/backend/apps/pisadmin/procurement/models.py @@ -0,0 +1,45 @@ +import logging +from typing import Iterable, Optional + +from django.db import models +from django.utils import timezone + +from dvadmin.utils.models import CoreModel, table_prefix + +logger = logging.getLogger(__name__) + + +class ProcMaterial(CoreModel): + """策采料号信息""" + + CATEGORY_CHOICES = ( + (1, "注塑"), + (2, "冲压"), + ) + + company_code = models.CharField(max_length=10, verbose_name="交易厂区", help_text="交易厂区(公司)") + material_code = models.CharField(max_length=50, verbose_name="料号", help_text="料号(物料编码)") + material_name_zh = models.CharField(max_length=50, verbose_name="中文品名", help_text="中文品名") + material_name_en = models.CharField(max_length=50, verbose_name="英文品名", help_text="英文品名", null=True, blank=True) + material_name_vi = models.CharField(max_length=50, verbose_name="越文品名", help_text="越文品名", null=True, blank=True) + specification = models.CharField(max_length=50, verbose_name="品名规格", help_text="品名规格") + unit = models.CharField(max_length=50, verbose_name="单位", help_text="单位") + source_code = models.CharField(max_length=50, verbose_name="来源码", help_text="来源码", null=True, blank=True) + model_type = models.CharField(max_length=50, verbose_name="機種別", help_text="機種別", null=True, blank=True) + product_category = models.CharField(max_length=50, verbose_name="产品分类", help_text="产品分类", null=True, blank=True) + group_code = models.CharField(max_length=50, verbose_name="分群码", help_text="分群码", null=True, blank=True) + material_group = models.CharField(max_length=50, verbose_name="物料群组", help_text="物料群组", null=True, blank=True) + market_class = models.CharField(max_length=50, verbose_name="市场分类", help_text="市场分类", null=True, blank=True) + approval_status = models.CharField(max_length=50, verbose_name="料号状态", help_text="料号状态(承认否)", null=True, blank=True) + partid_category_id = models.IntegerField(choices=CATEGORY_CHOICES, verbose_name="物料分类", help_text="物料分类(1:注塑,2:冲压)", null=True, blank=True) + status = models.IntegerField(default=1, verbose_name="启用否", help_text="启用否(1:启用 0:禁用)") + + class Meta: + db_table = table_prefix + "proc_materials" + verbose_name = "策采料号信息" + verbose_name_plural = verbose_name + ordering = ("-create_datetime",) + unique_together = ("company_code", "material_code") + + def __str__(self): + return self.material_name_zh or self.material_code \ No newline at end of file diff --git a/backend/apps/pisadmin/procurement/serializers.py b/backend/apps/pisadmin/procurement/serializers.py new file mode 100644 index 0000000..5519dc9 --- /dev/null +++ b/backend/apps/pisadmin/procurement/serializers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from dvadmin.utils.serializers import CustomModelSerializer + +from .models import ProcMaterial + + +class ProcMaterialSerializer(CustomModelSerializer): + """策采料号信息-序列化器""" + + class Meta: + model = ProcMaterial + fields = "__all__" + read_only_fields = ["id"] + + +class ProcMaterialCreateUpdateSerializer(CustomModelSerializer): + """策采料号信息 创建/更新""" + + material_name_zh = serializers.CharField(max_length=50) + specification = serializers.CharField(max_length=50) + unit = serializers.CharField(max_length=50) + + class Meta: + model = ProcMaterial + fields = "__all__" diff --git a/backend/apps/pisadmin/procurement/urls.py b/backend/apps/pisadmin/procurement/urls.py new file mode 100644 index 0000000..8d9fcf5 --- /dev/null +++ b/backend/apps/pisadmin/procurement/urls.py @@ -0,0 +1,9 @@ +from rest_framework import routers + +from .views import ProcMaterialViewSet + +router = routers.SimpleRouter() +router.register(r'proc_materials', ProcMaterialViewSet) + +urlpatterns = [] +urlpatterns += router.urls diff --git a/backend/apps/pisadmin/procurement/views.py b/backend/apps/pisadmin/procurement/views.py new file mode 100644 index 0000000..3416e45 --- /dev/null +++ b/backend/apps/pisadmin/procurement/views.py @@ -0,0 +1,16 @@ +from dvadmin.utils.viewset import CustomModelViewSet + +from .models import ProcMaterial +from .serializers import ProcMaterialSerializer, ProcMaterialCreateUpdateSerializer + + +class ProcMaterialViewSet(CustomModelViewSet): + """策采料号信息""" + + queryset = ProcMaterial.objects.all() + serializer_class = ProcMaterialSerializer + create_serializer_class = ProcMaterialCreateUpdateSerializer + update_serializer_class = ProcMaterialCreateUpdateSerializer + filter_fields = ["company_code", "material_code", "partid_category_id", "status"] + search_fields = ["company_code", "material_code", "material_name_zh", "material_name_en"] + ordering = ["-create_datetime"] diff --git a/backend/plugins/eip_sync/sync/adapters/raw_material.py b/backend/plugins/eip_sync/sync/adapters/raw_material.py new file mode 100644 index 0000000..690aee1 --- /dev/null +++ b/backend/plugins/eip_sync/sync/adapters/raw_material.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, Dict + +from sync.base import BaseSyncAdapter, SyncDirection +from sync.factory import SyncFactory + +logger = logging.getLogger(__name__) + + +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 _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 + 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 RawMaterialSyncAdapter(BaseSyncAdapter): + """EIP 原料料号资料抛转:同步原料料号主数据(策采相关)。""" + + adapter_name = "raw_material" + direction = SyncDirection.EIP_TO_PIS + + def validate(self, data: Dict[str, Any]) -> bool: + company = _first_str(data, "companyCode", "company_code") + material_code = _first_str(data, "materialCode", "material_code") + return bool(company and material_code) + + def transform_to_local(self, data: Dict[str, Any]) -> Dict[str, Any]: + company_code = _first_str(data, "companyCode", "company_code") + material_code = _first_str(data, "materialCode", "material_code") + material_name_zh = _first_str( + data, "materialNameZh", "material_name_zh", "materialName", "material_name" + ) + material_name_en = _first_str(data, "materialNameEn", "material_name_en") + material_name_vi = _first_str(data, "materialNameVi", "material_name_vi") + specification = _first_str(data, "specification") + unit = _first_str(data, "unit") + source_code = _first_str(data, "sourceCode", "source_code") + model_type = _first_str(data, "modelType", "model_type") + product_category = _first_str(data, "productCategory", "product_category") + group_code = _first_str(data, "groupCode", "group_code") + material_group = _first_str(data, "materialGroup", "material_group") + market_class = _first_str(data, "marketClass", "market_class") + approval_status = _first_str(data, "approvalStatus", "approval_status") + createtime = _parse_datetime(data.get("createtime")) + updatetime = _parse_datetime(data.get("updatetime")) + + external_id = f"{company_code}_{material_code}" + return { + "company_code": company_code, + "material_code": material_code, + "material_name_zh": material_name_zh, + "material_name_en": material_name_en, + "material_name_vi": material_name_vi, + "specification": specification, + "unit": unit, + "source_code": source_code, + "model_type": model_type, + "product_category": product_category, + "group_code": group_code, + "material_group": material_group, + "market_class": market_class, + "approval_status": approval_status, + "createtime": createtime, + "updatetime": updatetime, + "external_id": external_id, + } + + def save_to_local(self, transformed_data: Dict[str, Any]) -> bool: + from apps.pisadmin.procurement.models import ProcMaterial + + company_code = transformed_data.get("company_code") + material_code = transformed_data.get("material_code") + createtime = transformed_data.pop("createtime", None) + updatetime = transformed_data.pop("updatetime", None) + transformed_data.pop("external_id", "") + + defaults = { + "material_name_zh": transformed_data.get("material_name_zh") or "", + "material_name_en": transformed_data.get("material_name_en") or "", + "material_name_vi": transformed_data.get("material_name_vi") or "", + "specification": transformed_data.get("specification") or "", + "unit": transformed_data.get("unit") or "", + "source_code": transformed_data.get("source_code") or "", + "model_type": transformed_data.get("model_type") or "", + "product_category": transformed_data.get("product_category") or "", + "group_code": transformed_data.get("group_code") or "", + "material_group": transformed_data.get("material_group") or "", + "market_class": transformed_data.get("market_class") or "", + "approval_status": transformed_data.get("approval_status") or "", + } + + obj, created = ProcMaterial.objects.update_or_create( + company_code=company_code, + material_code=material_code, + defaults=defaults, + ) + + logger.info( + f"[RawMaterialSyncAdapter] {'创建' if created else '更新'} ProcMaterial: " + f"company_code={company_code}, material_code={material_code}" + ) + return True + + def transform_to_remote(self, data: Dict[str, Any]) -> Dict[str, Any]: + return {} + + +SyncFactory.register("raw_material", RawMaterialSyncAdapter) diff --git a/backend/plugins/eip_sync/sync/apps.py b/backend/plugins/eip_sync/sync/apps.py index 5fddfbf..24623ae 100644 --- a/backend/plugins/eip_sync/sync/apps.py +++ b/backend/plugins/eip_sync/sync/apps.py @@ -10,3 +10,5 @@ class SyncConfig(AppConfig): # Register adapters with SyncFactory from sync.adapters import misc_material # noqa: F401 from sync.adapters import pricing_result # noqa: F401 + from sync.adapters import vnd_quote_perms # noqa: F401 + from sync.adapters import raw_material # noqa: F401 diff --git a/backend/plugins/eip_sync/sync/manager.py b/backend/plugins/eip_sync/sync/manager.py index 2f73a36..978c9ff 100644 --- a/backend/plugins/eip_sync/sync/manager.py +++ b/backend/plugins/eip_sync/sync/manager.py @@ -19,6 +19,7 @@ class SyncManager: SUCCESS_MESSAGE_MISC = "物料信息已成功抛转至PIS" SUCCESS_MESSAGE_VENDOR_QUOTE_PERMISSION = "权限信息已成功跳转至PIS" + SUCCESS_MESSAGE_RAW_MATERIAL = "物料信息已成功推送至PIS" def __init__(self): self.logger = SyncLogger() @@ -37,6 +38,8 @@ class SyncManager: message = self.SUCCESS_MESSAGE_MISC elif adapter_name == "vendor_quote_permission": message = self.SUCCESS_MESSAGE_VENDOR_QUOTE_PERMISSION + elif adapter_name == "raw_material": + message = self.SUCCESS_MESSAGE_RAW_MATERIAL else: message = "同步成功" return {"Status": "success", "Message": message} diff --git a/backend/plugins/eip_sync/sync/views.py b/backend/plugins/eip_sync/sync/views.py index 410707a..496f4fb 100644 --- a/backend/plugins/eip_sync/sync/views.py +++ b/backend/plugins/eip_sync/sync/views.py @@ -60,3 +60,20 @@ class VendorQuotePermissionSyncView(APIView): 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) + + +class RawMaterialSyncView(APIView): + """ + EIP -> PIS: 原料料号资料抛转(策采相关)。 + + POST ``/api/sync/materials/raw`` + """ + + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs): + manager = SyncManager() + result = manager.process_eip_webhook("raw_material", 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 bc603b1..b3f508b 100644 --- a/backend/plugins/test/test_sync_misc_material.py +++ b/backend/plugins/test/test_sync_misc_material.py @@ -8,11 +8,12 @@ 查看 **HTTP 入参 / 出参**(接收请求与返回响应):: - pytest plugins/test/test_sync_misc_material.py -s -q + $env:PIS_TEST_USE_SQLITE="1"; pytest plugins/test/test_sync_misc_material.py -s -q (``-s`` 关闭输出捕获;否则仅在失败时可能看到部分输出。) """ import json +import sys import uuid import pytest @@ -20,9 +21,11 @@ from rest_framework import status from apps.pisadmin.basicinfo.models import SupplierUser from apps.pisadmin.miscprocurement.models import Inquiry, MiscProcMaterial +from apps.pisadmin.procurement.models import ProcMaterial from sync.adapters.misc_material import MiscMaterialSyncAdapter from sync.adapters.pricing_result import PricingResultSyncAdapter from sync.adapters.vnd_quote_perms import VendorQuotePermissionSyncAdapter +from sync.adapters.raw_material import RawMaterialSyncAdapter from sync.base import SyncStatus from sync.factory import SyncFactory from sync.manager import SyncManager @@ -87,34 +90,74 @@ def _quote_permission_payload( } +def _raw_material_payload( + company_code: str = "VC01", + material_code: str = "RM-001", + material_name_zh: str = "原料A", + specification: str = "SPEC-001", + unit: str = "KG", + **overrides, +) -> dict: + data = { + "companyCode": company_code, + "materialCode": material_code, + "materialNameZh": material_name_zh, + "materialNameEn": "Raw Material A", + "materialNameVi": "Nguyen Lieu A", + "specification": specification, + "unit": unit, + "sourceCode": "SRC-001", + "modelType": "MODEL-A", + "productCategory": "CATEGORY-A", + "groupCode": "GRP-001", + "materialGroup": "MG-001", + "marketClass": "MC-001", + "approvalStatus": "APPROVED", + "createtime": "2026-04-01T09:00:00", + "updatetime": "2026-04-08T10:00:00", + } + data.update(overrides) + return data + + +def _safe_print(text: str) -> None: + try: + print(text, flush=True) + except (UnicodeEncodeError, OSError): + pass + + def _log_http_roundtrip(title: str, url: str, payload, response) -> None: """在终端打印一次「接收到的请求」与「返回的响应」(需 ``pytest -s``)。""" try: - body_in = json.dumps(payload, ensure_ascii=False, indent=2) - except (TypeError, ValueError): - body_in = repr(payload) - data = getattr(response, "data", None) - if data is not None: try: - body_out = json.dumps(data, ensure_ascii=False, indent=2, default=str) + body_in = json.dumps(payload, ensure_ascii=False, indent=2) except (TypeError, ValueError): - body_out = repr(data) - else: - raw = getattr(response, "content", b"") or b"" - body_out = raw.decode("utf-8", errors="replace")[:4000] - print( - f"\n{'═' * 56}\n" - f" {title}\n" - f" ── 请求 (EIP → PIS) ─────────────────────────────\n" - f" POST {url}\n" - f" Content-Type: application/json\n" - f" body:\n{body_in}\n" - f" ── 响应 (PIS → EIP) ─────────────────────────────\n" - f" status: {response.status_code}\n" - f" body:\n{body_out}\n" - f"{'═' * 56}\n", - flush=True, - ) + body_in = repr(payload) + data = getattr(response, "data", None) + if data is not None: + try: + body_out = json.dumps(data, ensure_ascii=False, indent=2, default=str) + except (TypeError, ValueError): + body_out = repr(data) + else: + raw = getattr(response, "content", b"") or b"" + body_out = raw.decode("utf-8", errors="replace")[:4000] + sep = "=" * 56 + _safe_print( + f"\n{sep}\n" + f" {title}\n" + f" -- 请求 (EIP -> PIS) ------------------------\n" + f" POST {url}\n" + f" Content-Type: application/json\n" + f" body:\n{body_in}\n" + f" -- 响应 (PIS -> EIP) ------------------------\n" + f" status: {response.status_code}\n" + f" body:\n{body_out}\n" + f"{sep}\n" + ) + except Exception: + pass @pytest.mark.django_db @@ -418,9 +461,11 @@ def test_sync_factory_registers_eip_adapters(): assert "misc_material" in names assert "pricing_result" in names assert "vendor_quote_permission" in names + assert "raw_material" in names assert isinstance(SyncFactory.create("misc_material"), MiscMaterialSyncAdapter) assert isinstance(SyncFactory.create("pricing_result"), PricingResultSyncAdapter) assert isinstance(SyncFactory.create("vendor_quote_permission"), VendorQuotePermissionSyncAdapter) + assert isinstance(SyncFactory.create("raw_material"), RawMaterialSyncAdapter) @pytest.mark.django_db @@ -608,3 +653,142 @@ class TestVendorQuotePermissionSyncAPI: 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() + + +@pytest.mark.django_db +class TestRawMaterialSyncAdapter: + """RawMaterialSyncAdapter:校验、转换、落库(占位实现)。""" + + def test_validate_requires_company_and_material(self): + adapter = RawMaterialSyncAdapter() + assert adapter.validate({}) is False + assert adapter.validate({"companyCode": "A01"}) is False + assert adapter.validate({"materialCode": "P1"}) is False + assert adapter.validate({"companyCode": "A01", "materialCode": "P1"}) is True + assert adapter.validate({"company_code": "A01", "material_code": "P1"}) is True + + def test_validate_accepts_snake_case_fields(self): + adapter = RawMaterialSyncAdapter() + data = { + "company_code": "VC01", + "material_code": "RM-001", + } + assert adapter.validate(data) is True + + def test_transform_maps_all_eip_fields(self): + adapter = RawMaterialSyncAdapter() + data = _raw_material_payload() + t = adapter.transform_to_local(data) + assert t["company_code"] == "VC01" + assert t["material_code"] == "RM-001" + assert t["material_name_zh"] == "原料A" + assert t["material_name_en"] == "Raw Material A" + assert t["material_name_vi"] == "Nguyen Lieu A" + assert t["specification"] == "SPEC-001" + assert t["unit"] == "KG" + assert t["source_code"] == "SRC-001" + assert t["model_type"] == "MODEL-A" + assert t["product_category"] == "CATEGORY-A" + assert t["group_code"] == "GRP-001" + assert t["material_group"] == "MG-001" + assert t["market_class"] == "MC-001" + assert t["approval_status"] == "APPROVED" + assert t["external_id"] == "VC01_RM-001" + + def test_push_to_local_creates_proc_material(self): + adapter = RawMaterialSyncAdapter() + payload = _raw_material_payload(material_code="NEW-PROC-001") + op = adapter.push_to_local(payload) + assert op.status == SyncStatus.SUCCESS + obj = ProcMaterial.objects.get(company_code="VC01", material_code="NEW-PROC-001") + assert obj.material_name_zh == "原料A" + assert obj.material_name_en == "Raw Material A" + assert obj.specification == "SPEC-001" + assert obj.unit == "KG" + + def test_push_to_local_updates_existing_proc_material(self): + ProcMaterial.objects.create( + company_code="VC01", + material_code="UPDATE-PROC-001", + material_name_zh="旧名称", + specification="old", + unit="U", + ) + adapter = RawMaterialSyncAdapter() + op = adapter.push_to_local( + _raw_material_payload( + material_code="UPDATE-PROC-001", + material_name_zh="新名称", + specification="new", + ) + ) + assert op.status == SyncStatus.SUCCESS + obj = ProcMaterial.objects.get(company_code="VC01", material_code="UPDATE-PROC-001") + assert obj.material_name_zh == "新名称" + assert obj.specification == "new" + + def test_push_to_local_maps_all_extended_fields(self): + adapter = RawMaterialSyncAdapter() + payload = _raw_material_payload( + material_code="EXT-FIELD-001", + material_name_zh="扩展字段测试", + ) + payload["sourceCode"] = "SRC-TEST" + payload["modelType"] = "MODEL-TEST" + payload["productCategory"] = "CAT-TEST" + payload["groupCode"] = "GRP-TEST" + payload["materialGroup"] = "MG-TEST" + payload["marketClass"] = "MC-TEST" + payload["approvalStatus"] = "PENDING" + op = adapter.push_to_local(payload) + assert op.status == SyncStatus.SUCCESS + obj = ProcMaterial.objects.get(company_code="VC01", material_code="EXT-FIELD-001") + assert obj.source_code == "SRC-TEST" + assert obj.model_type == "MODEL-TEST" + assert obj.product_category == "CAT-TEST" + assert obj.group_code == "GRP-TEST" + assert obj.material_group == "MG-TEST" + assert obj.market_class == "MC-TEST" + assert obj.approval_status == "PENDING" + + +@pytest.mark.django_db +class TestRawMaterialSyncAPI: + """POST /api/sync/materials/raw""" + + URL = "/api/sync/materials/raw" + + def test_post_success(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + payload = _raw_material_payload(material_code="API-RM-001") + 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 ProcMaterial.objects.filter(material_code="API-RM-001").exists() + obj = ProcMaterial.objects.get(material_code="API-RM-001") + assert obj.material_name_zh == "原料A" + assert obj.company_code == "VC01" + + def test_post_validation_error(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + bad = {"companyCode": ""} + 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 = _raw_material_payload(material_code="NOAUTH-RM-001") + 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 "物料信息已成功推送至PIS" in response.data["Message"] + assert ProcMaterial.objects.filter(material_code="NOAUTH-RM-001").exists()