diff --git a/backend/application/settings.py b/backend/application/settings.py index 47a2033..8fc6e8d 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 726138e..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 ( @@ -106,7 +106,7 @@ urlpatterns = ( name="schema-redoc", ), path( - "api/sync/material/misc", + "api/sync/materials/misc", MiscMaterialSyncView.as_view(), name="sync-misc-material", ), @@ -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/dvadmin/system/views/login.py b/backend/dvadmin/system/views/login.py index c51ed64..d639cd1 100644 --- a/backend/dvadmin/system/views/login.py +++ b/backend/dvadmin/system/views/login.py @@ -108,6 +108,11 @@ class LoginSerializer(TokenObtainPairSerializer): raise CustomValidationError("账号已被锁定,联系管理员解锁") # 必须重置用户名为username,否则使用邮箱手机号登录会提示密码错误 attrs['username'] = user.username + # 采购端登录:账号若仅有供应商端角色(supplier / supplier_*),拒绝(与供应商端入口对称) + user_roles = user.role.all() + role_keys = [r.key for r in user_roles] + if role_keys and all(_is_supplier_portal_role_key(k) for k in role_keys): + raise CustomValidationError("该账号为供应商账号,请使用供应商入口登录") try: data = super().validate(attrs) except Exception as e: 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 80a155a..496f4fb 100644 --- a/backend/plugins/eip_sync/sync/views.py +++ b/backend/plugins/eip_sync/sync/views.py @@ -30,7 +30,7 @@ class MiscMaterialSyncView(APIView): EIP -> PIS: miscellaneous procurement material master (杂采料号). Authenticated via dvadmin-ak-sk (X-NSF-* signature headers). - POST ``/api/sync/material/misc`` + POST ``/api/sync/materials/misc`` """ # authentication_classes = [AkSkAuthentication] @@ -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 b8e2444..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 @@ -323,9 +366,9 @@ class TestPricingResultSyncAdapter: @pytest.mark.django_db class TestMiscMaterialSyncAPI: - """POST /api/sync/material/misc(DRF force_authenticate 绕过 AK/SK 验签,仅测业务链)。""" + """POST /api/sync/materials/misc(DRF force_authenticate 绕过 AK/SK 验签,仅测业务链)。""" - URL = "/api/sync/material/misc" + URL = "/api/sync/materials/misc" def test_post_success(self, api_client, admin_user): api_client.force_authenticate(user=admin_user) @@ -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() diff --git a/web/src/views/pisadmin/dashboard/BuyerDashboard.vue b/web/src/views/pisadmin/dashboard/BuyerDashboard.vue index 5ab98fa..585c128 100644 --- a/web/src/views/pisadmin/dashboard/BuyerDashboard.vue +++ b/web/src/views/pisadmin/dashboard/BuyerDashboard.vue @@ -58,7 +58,7 @@
| @@ -258,6 +258,13 @@ const getMsg = (): void => { }); }; +/** 代办任务:后端「报价结束」在界面上展示为「待比议价」 */ +function getTaskStatusDisplay(status: string | undefined): string { + if (!status) return ''; + if (status === '报价结束') return '待比议价'; + return status; +} + function getStatusClass(status: string) { if (status?.includes('比价') || status?.includes('发布')) return 'status-badge status-blue'; if (status?.includes('议价') || status?.includes('紧急')) return 'status-badge status-pink'; @@ -278,6 +285,14 @@ function isTenderTask(task: any): boolean { return task?.method === '招标'; } +/** 招标:已超过投标截止时间(与「剩余:已结束」一致,用于灰色渲染) */ +function isTenderPastBidEnd(task: any): boolean { + if (!isTenderTask(task)) return false; + const end = task?.bid_end_time; + if (!end) return false; + return Date.now() >= new Date(end).getTime(); +} + function formatDateTimeYMDHM(iso: string | Date | null | undefined): string { if (!iso) return '-'; const d = new Date(iso); @@ -339,9 +354,7 @@ function getInquiryHoursToQuoteDeadline(task: any): number | null { } function getDeadlinePrimaryLabel(task: any): string { - return isTenderTask(task) - ? t('message.pages.home.buyerDashboard.deadline.bidTime') - : t('message.pages.home.buyerDashboard.deadline.quoteDeadline'); + return isTenderTask(task) ? '投标时间范围:' : '报价截止时间:'; } function getDeadlinePrimaryValue(task: any): string { @@ -372,7 +385,7 @@ function getDeadlineRemainingValue(task: any): string { /** 第一行时间 + 第二行「剩余」数值共用样式类 */ function getDeadlineValueClass(task: any): string { if (isTenderTask(task)) { - return 'deadline-tender-accent'; + return isTenderPastBidEnd(task) ? 'deadline-expired' : 'deadline-tender-accent'; } const h = getInquiryHoursToQuoteDeadline(task); if (h == null || task?.quote_deadline == null) return ''; @@ -785,7 +798,6 @@ tr:last-child td { .deadline-expired { color: #9ca3af !important; font-weight: 500; - text-decoration: line-through; } /* 询价:距截止 >48h 时的强调色(参考稿橙红) */ .deadline-accent-normal { diff --git a/web/src/views/system/login/api.ts b/web/src/views/system/login/api.ts index 9c74b6c..0b2dd29 100644 --- a/web/src/views/system/login/api.ts +++ b/web/src/views/system/login/api.ts @@ -1,4 +1,17 @@ import { request } from "/@/utils/service"; +import type { LoginRoleInfo } from "./types"; + +/** 与后端 login._is_supplier_portal_role_key 一致:供应商端允许的角色 key */ +export function isSupplierPortalRoleKey(key: string | undefined | null): boolean { + if (!key) return false; + return key === "supplier" || key.startsWith("supplier_"); +} + +/** 账号是否仅有供应商端角色(此类账号仅允许走 /api/login/supplier/) */ +export function isSupplierOnlyAccount(roleInfo: LoginRoleInfo[] | undefined | null): boolean { + if (!roleInfo?.length) return false; + return roleInfo.every((r) => isSupplierPortalRoleKey(r.key)); +} export function getCaptcha() { return request({ diff --git a/web/src/views/system/login/component/account.vue b/web/src/views/system/login/component/account.vue index b9396b8..29f5ce9 100644 --- a/web/src/views/system/login/component/account.vue +++ b/web/src/views/system/login/component/account.vue @@ -153,6 +153,11 @@ export default defineComponent({ const res = await loginMethod({ ...state.ruleForm, password: Md5.hashStr(state.ruleForm.password) }); if (res.code === 2000) { const { data } = res; + if (!isSupplierPortal && loginApi.isSupplierOnlyAccount(data.role_info)) { + ElMessage.error('该账号为供应商账号,请使用供应商入口登录'); + refreshCaptcha(); + return; + } Cookies.set('username', res.data.username); Session.set('token', res.data.access); useUserInfo().setPwdChangeCount(data.pwd_change_count); diff --git a/web/src/views/system/login/index.vue b/web/src/views/system/login/index.vue index b02358b..5728860 100644 --- a/web/src/views/system/login/index.vue +++ b/web/src/views/system/login/index.vue @@ -278,6 +278,15 @@ onMounted(() => { height: 100%; + .login-buyer-portal-hint { + margin: 0 40px 8px; + flex-shrink: 0; + } + .login-buyer-portal-hint-link { + color: var(--el-color-primary); + margin-left: 4px; + font-weight: 600; + } .login-right-warp-main-title { height: 130px; font-size: 32px; diff --git a/web/src/views/system/login/types.ts b/web/src/views/system/login/types.ts index 1f3086f..a46365f 100644 --- a/web/src/views/system/login/types.ts +++ b/web/src/views/system/login/types.ts @@ -1,4 +1,3 @@ - export interface OAuth2Backend { app_name: string; backend_name: string; @@ -6,3 +5,10 @@ export interface OAuth2Backend { authentication_url: string; } +/** JWT 登录成功 data.role_info 单项(与后端 Users.role 一致) */ +export interface LoginRoleInfo { + id: number; + name: string; + key: string; +} + |