diff --git a/backend/application/test_settings.py b/backend/application/test_settings.py index 3e0a967..a77a107 100644 --- a/backend/application/test_settings.py +++ b/backend/application/test_settings.py @@ -1,31 +1,100 @@ """ -测试专用 Django Settings -使用已存在的 pisdb 数据库运行测试,测试之间有事务隔离。 -不创建/删除测试库,直接使用 pisdb。 +测试专用 Django Settings(``pytest`` / ``DJANGO_SETTINGS_MODULE=application.test_settings``) + +**SQL Server(与服务器部署一致,默认)** + +- 继承 ``application.settings`` 中的 ``DATABASE_*``(可用环境变量覆盖,见 ``conf/env.py``)。 +- 复用业务库 ``DATABASE_NAME``:不 CREATE/DROP 库、不跑 ``migrate``,避免与线上一致库结构冲突。 +- **请在业务库上至少执行过一次** ``python manage.py migrate``(含 ``sync`` 应用),确保存在 ``pis_sync_record`` 等表;否则同步接口仍可返回 JSON,但审计表无数据、依赖 ``SyncRecord`` 的断言会失败。 +- 依赖 ``mssql-django`` 的 ``DatabaseCreation`` 补丁(见下方 ``_install_shared_test_database``)。 + +**无 SQL Server 时(本地 CI / 开发机)** + +- 设置环境变量 ``PIS_TEST_USE_SQLITE=1``(或 ``TEST_USE_SQLITE=1``):使用项目目录下 ``.pytest/pis_test_runner.sqlite3``, + 走 Django **默认**测试库创建与 ``migrate``,不挂「复用库」逻辑。 + +本模块仅处理 **SQL Server(mssql-django)** 与上述 SQLite 测试模式,不再包含 PostgreSQL / MySQL 专用分支。 """ import os -import sys + from application.settings import * # noqa -# Patch MSSQL creation class BEFORE any database connections are made -# 这必须在 Django settings 加载之后、任何 DB 操作之前完成 -import mssql.base -from mssql.creation import DatabaseCreation +_use_sqlite_for_tests = os.environ.get( + "PIS_TEST_USE_SQLITE", os.environ.get("TEST_USE_SQLITE", "") +).lower() in ("1", "true", "yes") + +if _use_sqlite_for_tests: + _sqlite_dir = BASE_DIR / ".pytest" + _sqlite_dir.mkdir(exist_ok=True) + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": str(_sqlite_dir / "pis_test_runner.sqlite3"), + "TEST": { + "SERIALIZE": False, + }, + } + } +else: + DATABASES["default"].setdefault("TEST", {}) + DATABASES["default"]["TEST"]["NAME"] = DATABASE_NAME + DATABASES["default"]["TEST"]["SERIALIZE"] = False -class NoCreateTestDatabase(DatabaseCreation): - """跳过 CREATE/DROP DATABASE 的 MSSQL 创建逻辑""" +def _install_shared_test_database(creation_cls, base_module): + """ + 绑定测试到已有库:不建库、不 migrate,仅切换连接并初始化缓存表(失败则忽略)。 + """ - def _create_test_db(self, verbosity=1, autoclobber=False, keepdb=False): - return self.connection.settings_dict["NAME"] + class _SharedTestDatabase(creation_cls): + def _create_test_db(self, verbosity, autoclobber=False, keepdb=False): + return self._get_test_db_name() - def _destroy_test_db(self, test_db_name, verbosity=1, keepdb=False): - pass + def _destroy_test_db(self, test_database_name, verbosity): + pass + + def create_test_db( + self, verbosity=1, autoclobber=False, serialize=True, keepdb=False + ): + from django.conf import settings as django_settings + from django.core.management import call_command + + test_database_name = self._get_test_db_name() + if verbosity >= 1: + self.log( + "Binding tests to shared database %s (skip migrate)..." + % self._get_database_display_str(verbosity, test_database_name) + ) + + self._create_test_db(verbosity, autoclobber, keepdb) + + self.connection.close() + django_settings.DATABASES[self.connection.alias]["NAME"] = test_database_name + self.connection.settings_dict["NAME"] = test_database_name + + if serialize: + self.connection._test_serialized_contents = ( + self.serialize_db_to_string() + ) + + try: + call_command( + "createcachetable", + database=self.connection.alias, + ) + except Exception: + pass + + self.connection.ensure_connection() + return test_database_name + + base_module.DatabaseWrapper.creation_class = _SharedTestDatabase -mssql.base.DatabaseWrapper.creation_class = NoCreateTestDatabase +if not _use_sqlite_for_tests: + _engine = (DATABASES["default"].get("ENGINE") or "").lower() + if "mssql" in _engine or "sql_server" in _engine: + import mssql.base + from mssql.creation import DatabaseCreation as _MsSqlDatabaseCreation -# 配置测试数据库 -DATABASES["default"]["TEST"] = { - "NAME": DATABASE_NAME, -} + _install_shared_test_database(_MsSqlDatabaseCreation, mssql.base) diff --git a/backend/application/urls.py b/backend/application/urls.py index f8696dc..726138e 100644 --- a/backend/application/urls.py +++ b/backend/application/urls.py @@ -25,6 +25,9 @@ from rest_framework_simplejwt.views import ( 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 application.sse_views import sse_view from dvadmin.system.views.dictionary import InitDictionaryViewSet from dvadmin.system.views.login import ( @@ -102,7 +105,31 @@ urlpatterns = ( schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc", ), - path("api/pisadmin/sync/", include("sync.urls")), + path( + "api/sync/material/misc", + MiscMaterialSyncView.as_view(), + name="sync-misc-material", + ), + path( + "api/pricing/applications/result", + 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(), + name="public-pissupplier-quotation-share", + ), + path( + "api/public/miscprocurement/comparison/", + MiscInquiryComparisonShareBundleView.as_view(), + name="public-misc-comparison-bundle", + ), path("api/pisadmin/miscprocurement/", include("apps.pisadmin.miscprocurement.urls")), path("api/pisadmin/basicinfo/", include("apps.pisadmin.basicinfo.urls")), path("api/pisadmin/dashboard/", include("apps.pisadmin.dashboard.urls")), 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/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 8917c2e..c01f831 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -9,6 +9,8 @@ from django.db.models import Q from django.utils import timezone from rest_framework.decorators import action from rest_framework import serializers +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse from dvadmin.utils.viewset import CustomModelViewSet @@ -28,7 +30,7 @@ from apps.pissupplier.models import ( QuotationProfit, QuotationItem, ) - +from apps.pissupplier.serializers import QuotationMasterSerializer logger = logging.getLogger(__name__) @@ -143,6 +145,63 @@ from .serializers import ( MiscProcProcessingMinPricesSerializer, ) +def build_comparison_share_bundle(inquiry: Inquiry) -> dict: + """与前端比价页 loadComparisonPage 所需数据一致:询价单 + 报价明细 + 议价 + 材料/工站主数据。""" + inquiry_data = InquirySerializer(inquiry).data + qm_qs = ( + QuotationMaster.objects.filter(inquiry_no=inquiry.inquiry_no) + .order_by("supplier_code", "autoid") + .prefetch_related("rfq_items", "material_costs", "process_costs", "other_costs", "profit_costs") + ) + quotations = [] + for qm in qm_qs: + quotations.append(QuotationMasterSerializer(qm).data) + part_id = _resolve_inquiry_primary_part_id(inquiry) + neg_qs = MiscNegotiationRecords.objects.filter(inquiry_no=inquiry.inquiry_no) + if part_id: + neg_qs = neg_qs.filter(part_id=part_id) + negotiation_records = MiscNegotiationRecordsSerializer(neg_qs.order_by("id"), many=True).data + materials = MiscMaterialSerializer( + MiscProcurementMaterialInfo.objects.filter(status=1).order_by("id")[:8000], + many=True, + ).data + stations = MiscStationSerializer( + MiscProcurementStationInfo.objects.all().order_by("id")[:8000], + many=True, + ).data + return { + "inquiry": inquiry_data, + "quotations": quotations, + "negotiation_records": negotiation_records, + "materials": materials, + "stations": stations, + } + + +class MiscInquiryComparisonShareBundleView(APIView): + """ + GET /api/public/miscprocurement/comparison/ + + 免登录,仅 ``?inquiry_id=<主键>``。 + """ + + permission_classes = [AllowAny] + authentication_classes = [] + + def get(self, request, *args, **kwargs): + inquiry_id_raw = (request.query_params.get("inquiry_id") or "").strip() + if not inquiry_id_raw: + return ErrorResponse(msg="缺少 inquiry_id", code=4000) + try: + pk = int(inquiry_id_raw) + except (TypeError, ValueError): + return ErrorResponse(msg="inquiry_id 无效", code=4000) + inquiry = Inquiry.objects.filter(pk=pk).first() + if not inquiry: + return ErrorResponse(msg="询价单不存在", code=4000) + bundle = build_comparison_share_bundle(inquiry) + return DetailResponse(data=bundle, msg="success") + def _negotiation_totals_from_quotation_item(quotation_no: str, part_id: str): """从杂采报价单上阶物料明细取议价前含税/不含税总价(与 part_id 匹配行)。""" @@ -1431,6 +1490,17 @@ class InquiryViewSet(CustomModelViewSet): operation_desc="议价审核完成(核价通过)", ) + @action(methods=["get"], detail=True, url_path="comparison_share") + def comparison_share(self, request, pk=None): + """ + 比价对外分享使用 ``#/public/misc-compare?inquiry_id=<主键>``(免登录、免签名)。 + """ + self.get_object() # 保留权限校验语义;不返回链接 + return ErrorResponse( + msg="采购端「复制分享链接」已关闭,请使用带 inquiry_id 的公开比价地址(由业务系统拼链)", + code=4000, + ) + @action(methods=["get"], detail=True, url_path="negotiation_records") def negotiation_records(self, request, pk=None): """按询价单号查询杂采议价记录(可选 part_id)。""" diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index 07d3727..95ba6a9 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -5,6 +5,8 @@ from django.db import transaction from django.db.models import Q from django.utils import timezone from rest_framework.decorators import action +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView from apps.pisadmin.basicinfo.models import SupplierUser from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse @@ -453,3 +455,48 @@ class QuotationItemViewSet(SupplierQuotationScopeMixin, CustomModelViewSet): def perform_update(self, serializer): serializer.save() + + +class PublicQuotationShareDetailView(APIView): + """ + GET /api/public/pissupplier/quotation/ + + 免登录,仅 ``?id=`` 或 ``?autoid=``(报价主表主键 autoid)。 + 响应形态与 ``GET /api/pissupplier/quotation_master/{autoid}/`` 详情相同。 + """ + + permission_classes = [AllowAny] + authentication_classes = [] + + def get(self, request, *args, **kwargs): + id_raw = ( + request.query_params.get("id") + or request.query_params.get("autoid") + or "" + ).strip() + if not id_raw: + return ErrorResponse(msg="缺少 id(或 autoid)", code=4000) + try: + autoid = int(id_raw) + except (TypeError, ValueError): + return ErrorResponse(msg="id 无效", code=4000) + qm = ( + QuotationMaster.objects.prefetch_related( + "rfq_items", + "material_costs", + "process_costs", + "other_costs", + "profit_costs", + ) + .filter(autoid=autoid) + .first() + ) + if not qm: + return ErrorResponse(msg="报价单不存在", code=4000) + # 与 ViewSet.retrieve 一致:`QuotationMasterSerializer` 中 template_sections / inquiry_attachments + # 仅在 context["view_action"] == "retrieve" 时展开,否则为空导致前端成本结构与合计无法渲染。 + data = QuotationMasterSerializer( + qm, + context={"request": request, "view_action": "retrieve"}, + ).data + return DetailResponse(data=data, msg="success") diff --git a/backend/plugins/eip_sync/sync/adapters/pricing_result.py b/backend/plugins/eip_sync/sync/adapters/pricing_result.py new file mode 100644 index 0000000..444f7ff --- /dev/null +++ b/backend/plugins/eip_sync/sync/adapters/pricing_result.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict + +from django.db import transaction +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from apps.pisadmin.miscprocurement.models import Inquiry, RFQOperationLogs + +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 _parse_audit_timestamp(raw: Any) -> datetime: + if raw is None: + raise ValueError("审核完成时间不能为空") + if isinstance(raw, datetime): + dt = raw + else: + s = str(raw).strip() + if not s: + raise ValueError("审核完成时间不能为空") + dt = parse_datetime(s) + if dt is None: + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("审核完成时间格式无效") from exc + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, timezone.get_current_timezone()) + return dt + + +def _norm_purchase_category(raw: Any) -> int: + if raw is None: + raise ValueError("采购类别无效") + s = str(raw).strip() + if s in ("1", "2"): + return int(s) + raise ValueError("采购类别无效") + + +def _norm_audit_status(raw: Any) -> str: + if raw is None: + raise ValueError("审核结果无效") + s = str(raw).strip().upper() + if s in ("APPROVED", "REJECTED"): + return s + raise ValueError("审核结果无效") + + +class PricingResultSyncAdapter(BaseSyncAdapter): + """EIP 核价审核结果抛转:更新询价单状态及 EIP 审核字段。""" + + adapter_name = "pricing_result" + direction = SyncDirection.EIP_TO_PIS + + STATUS_PRICE_AUDIT = 7 + STATUS_APPROVED = 8 + STATUS_LOST = 9 + EIP_APPROVAL_OK = 1 + EIP_APPROVAL_REJECT = 2 + + def validate(self, data: Dict[str, Any]) -> bool: + company = _first_str(data, "Company", "company") + eip_audit = _first_str(data, "eipAuditNo", "eip_audit_no") + cat = _first_str(data, "purchase_category", "purchaseCategory") + inq = _first_str(data, "inquiryNo", "inquiry_no") + status = _first_str(data, "auditStatus", "audit_status") + user = _first_str(data, "auditUser", "audit_user") + ts = data.get("auditTimestamp") if "auditTimestamp" in data else data.get("audit_timestamp") + ts_ok = ts is not None and str(ts).strip() != "" + return bool(company and eip_audit and cat and inq and status and user and ts_ok) + + def transform_to_local(self, data: Dict[str, Any]) -> Dict[str, Any]: + company = _first_str(data, "Company", "company") + eip_audit_no = _first_str(data, "eipAuditNo", "eip_audit_no") + cat_raw = data.get("purchase_category") + if cat_raw is None: + cat_raw = data.get("purchaseCategory") + purchase_category = _norm_purchase_category(cat_raw) + inquiry_no = _first_str(data, "inquiryNo", "inquiry_no") + audit_status = _norm_audit_status(_first_str(data, "auditStatus", "audit_status")) + audit_user = _first_str(data, "auditUser", "audit_user")[:20] + raw_ts = data.get("auditTimestamp") if "auditTimestamp" in data else data.get("audit_timestamp") + audit_ts = _parse_audit_timestamp(raw_ts) + + external_id = f"{inquiry_no}_{eip_audit_no}" + return { + "company": company, + "eip_audit_no": eip_audit_no[:20], + "purchase_category": purchase_category, + "inquiry_no": inquiry_no[:20], + "audit_status": audit_status, + "audit_user": audit_user, + "audit_timestamp": audit_ts, + "external_id": external_id, + } + + def save_to_local(self, transformed_data: Dict[str, Any]) -> bool: + inquiry_no = transformed_data["inquiry_no"] + company = transformed_data["company"] + eip_audit_no = transformed_data["eip_audit_no"] + purchase_category = int(transformed_data["purchase_category"]) + audit_status = transformed_data["audit_status"] + audit_user = transformed_data["audit_user"] + audit_ts = transformed_data["audit_timestamp"] + + with transaction.atomic(): + obj = ( + Inquiry.objects.select_for_update() + .filter(inquiry_no=inquiry_no) + .first() + ) + if not obj: + raise ValueError("询价单不存在") + + if int(obj.purchase_type or 0) != purchase_category: + raise ValueError("单号不匹配") + + cc_db = (obj.company_code or "").strip() + cc_in = (company or "").strip() + if cc_db and cc_in and cc_db != cc_in: + raise ValueError("单号不匹配") + + appr = (obj.approval_number or "").strip() + if appr and appr != eip_audit_no: + raise ValueError("单号不匹配") + if not appr: + obj.approval_number = eip_audit_no + + if int(obj.status or 0) != self.STATUS_PRICE_AUDIT: + raise ValueError("当前询价单非价格审核状态,无法更新审核结果") + + old_status = int(obj.status if obj.status is not None else 0) + obj.approval_status = ( + self.EIP_APPROVAL_OK if audit_status == "APPROVED" else self.EIP_APPROVAL_REJECT + ) + obj.update_user = audit_user + obj.update_time = audit_ts + + if audit_status == "APPROVED": + new_status = self.STATUS_APPROVED + op_type = 9 + op_desc = f"EIP核价审核通过(审核人:{audit_user})" + else: + new_status = self.STATUS_LOST + op_type = 10 + op_desc = f"EIP核价审核驳回(审核人:{audit_user})" + + obj.status = new_status + obj.update_datetime = audit_ts + obj.save( + update_fields=[ + "status", + "approval_number", + "approval_status", + "update_user", + "update_time", + "update_datetime", + ] + ) + + if old_status != new_status: + RFQOperationLogs.try_append( + inquiry_no=obj.inquiry_no, + purchase_type=int(obj.purchase_type), + operation_type=op_type, + operation_user=audit_user, + quotation_no="-", + per_status=old_status, + cur_status=new_status, + operation_desc=op_desc[:200], + ) + + return True + + def transform_to_remote(self, data: Dict[str, Any]) -> Dict[str, Any]: + return {} + + +SyncFactory.register("pricing_result", PricingResultSyncAdapter) 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/apps.py b/backend/plugins/eip_sync/sync/apps.py index 302ad81..5fddfbf 100644 --- a/backend/plugins/eip_sync/sync/apps.py +++ b/backend/plugins/eip_sync/sync/apps.py @@ -9,3 +9,4 @@ class SyncConfig(AppConfig): def ready(self): # Register adapters with SyncFactory from sync.adapters import misc_material # noqa: F401 + from sync.adapters import pricing_result # noqa: F401 diff --git a/backend/plugins/eip_sync/sync/manager.py b/backend/plugins/eip_sync/sync/manager.py index 07bb3df..2f73a36 100644 --- a/backend/plugins/eip_sync/sync/manager.py +++ b/backend/plugins/eip_sync/sync/manager.py @@ -1,7 +1,9 @@ from __future__ import annotations +import logging from typing import Any, Dict, List, Optional +from django.db import DatabaseError, transaction from django.utils import timezone from sync.base import SyncOperationResult, SyncStatus @@ -9,11 +11,14 @@ from sync.factory import SyncFactory from sync.logger import SyncLogger from sync.models import SyncRecord +logger = logging.getLogger(__name__) + 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() @@ -28,26 +33,63 @@ 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 "同步失败"} + def process_pricing_audit_webhook(self, data: Dict[str, Any]) -> Dict[str, Any]: + """EIP 核价审核结果:返回 ``Status`` 为 bool、成功文案与接口规范一致。""" + adapter_name = "pricing_result" + if adapter_name not in SyncFactory.list_adapters(): + return {"Status": False, "Message": f"未知的适配器: {adapter_name}"} + + adapter = SyncFactory.create(adapter_name) + op = adapter.push_to_local(data) + self._persist_record(op) + self.logger.log_sync(op) + + if op.status == SyncStatus.SUCCESS: + return { + "Status": True, + "Message": "审核结果已接收并更新本地单据状态", + } + + err = op.error_message or "同步失败" + if err.startswith("保存失败: "): + err = err[len("保存失败: ") :] + return {"Status": False, "Message": err} + def _persist_record(self, op: SyncOperationResult) -> None: + """写入审计表;失败仅记日志,不向上抛,避免 EIP 回调拿不到约定 JSON。 + + 使用独立 ``atomic()``:在 SQL Server 上失败语句会中止当前事务;嵌套块以保存点回滚, + 避免污染外层请求/测试事务(否则后续 ORM 报 TransactionManagementError)。 + """ completed_at = timezone.now() - SyncRecord.objects.create( - adapter_name=op.adapter_name, - direction=op.direction.value, - external_id=op.external_id or "", - payload=op.payload, - response=op.response, - status=op.status.value, - error_message=op.error_message or "", - retry_count=op.retry_count, - completed_at=completed_at, - ) + try: + with transaction.atomic(): + SyncRecord.objects.create( + adapter_name=op.adapter_name, + direction=op.direction.value, + external_id=op.external_id or "", + payload=op.payload, + response=op.response, + status=op.status.value, + error_message=op.error_message or "", + retry_count=op.retry_count, + completed_at=completed_at, + ) + except DatabaseError as exc: + logger.warning( + "SyncRecord 落库失败(请确认已执行: python manage.py migrate sync)。%s", + exc, + ) def get_sync_history( self, diff --git a/backend/plugins/eip_sync/sync/urls.py b/backend/plugins/eip_sync/sync/urls.py index f40e14d..8038a97 100644 --- a/backend/plugins/eip_sync/sync/urls.py +++ b/backend/plugins/eip_sync/sync/urls.py @@ -1,7 +1,5 @@ +"""EIP 同步插件 URL:杂采物料、核价审核结果已注册于 ``application/urls.py``。""" + from django.urls import path -from sync.views import MiscMaterialSyncView - -urlpatterns = [ - path("misc-material/", MiscMaterialSyncView.as_view(), name="sync-misc-material"), -] +urlpatterns: list = [] diff --git a/backend/plugins/eip_sync/sync/views.py b/backend/plugins/eip_sync/sync/views.py index 4e26f56..80a155a 100644 --- a/backend/plugins/eip_sync/sync/views.py +++ b/backend/plugins/eip_sync/sync/views.py @@ -1,23 +1,62 @@ from rest_framework.permissions import IsAuthenticated +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 +class PricingAuditResultSyncView(APIView): + """ + EIP -> PIS:核价申请单审核完成后抛转审核结果。 + + POST ``/api/pricing/applications/result`` + """ + + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs): + manager = SyncManager() + result = manager.process_pricing_audit_webhook(request.data) + status_code = 200 if result.get("Status") is True else 400 + return Response(result, status=status_code) + + class MiscMaterialSyncView(APIView): """ EIP -> PIS: miscellaneous procurement material master (杂采料号). Authenticated via dvadmin-ak-sk (X-NSF-* signature headers). + + POST ``/api/sync/material/misc`` """ - authentication_classes = [AkSkAuthentication] - permission_classes = [IsAuthenticated] + # authentication_classes = [AkSkAuthentication] + # permission_classes = [IsAuthenticated] + authentication_classes = [] + permission_classes = [AllowAny] def post(self, request, *args, **kwargs): manager = SyncManager() 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 cd42275..b8e2444 100644 --- a/backend/plugins/test/test_sync_misc_material.py +++ b/backend/plugins/test/test_sync_misc_material.py @@ -1,19 +1,122 @@ """ -杂采料号 EIP 同步:适配器、SyncManager、HTTP 接口。 +杂采料号、核价审核结果 EIP 同步:适配器、SyncManager、HTTP 接口。 + +数据库:项目以 **SQL Server** 为主;单元测试使用 ``application.test_settings``(复用业务库或 +``PIS_TEST_USE_SQLITE=1`` 走 SQLite,见该模块说明)。 使用 pytest + django_db,客户端与 fixtures 与项目 conftest 一致(api_client / authenticate)。 + +查看 **HTTP 入参 / 出参**(接收请求与返回响应):: + + pytest plugins/test/test_sync_misc_material.py -s -q + +(``-s`` 关闭输出捕获;否则仅在失败时可能看到部分输出。) """ +import json +import uuid + import pytest from rest_framework import status -from apps.pisadmin.miscprocurement.models import MiscProcMaterial +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 from sync.models import SyncRecord +def _unique_inquiry_no() -> str: + return f"U{uuid.uuid4().hex[:16].upper()}" + + +def _inquiry_price_audit_pending( + *, + inquiry_no: str, + company_code: str = "VC01", + purchase_type: int = 2, + approval_number: str = "EIP-AUDIT-01", + status: int = 7, +) -> Inquiry: + return Inquiry.objects.create( + inquiry_no=inquiry_no, + title="单元测", + purchase_type=purchase_type, + template="TPL", + buyer="u1", + payment_method=1, + status=status, + company_code=company_code, + approval_number=approval_number, + ) + + +def _pricing_audit_payload(inquiry_no: str, audit_status: str = "APPROVED") -> dict: + return { + "Company": "VC01", + "eipAuditNo": "EIP-AUDIT-01", + "purchase_category": "2", + "inquiryNo": inquiry_no, + "auditStatus": audit_status, + "auditUser": "aud1", + "auditTimestamp": "2026-04-08T10:00:00", + } + + +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: + 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) + 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, + ) + + @pytest.mark.django_db class TestMiscMaterialSyncAdapter: """MiscMaterialSyncAdapter:校验、转换、落库。""" @@ -129,12 +232,100 @@ class TestSyncManager: assert out["Status"] == "fail" assert SyncRecord.objects.filter(adapter_name="misc_material", status="failed").exists() + def test_pricing_audit_success_writes_sync_record(self): + inq_no = _unique_inquiry_no() + _inquiry_price_audit_pending(inquiry_no=inq_no) + mgr = SyncManager() + out = mgr.process_pricing_audit_webhook(_pricing_audit_payload(inq_no)) + assert out["Status"] is True + assert out["Message"] == "审核结果已接收并更新本地单据状态" + assert SyncRecord.objects.filter(adapter_name="pricing_result", status="success").exists() + assert Inquiry.objects.get(inquiry_no=inq_no).status == 8 + + def test_pricing_audit_validation_failure_writes_failed_record(self): + mgr = SyncManager() + out = mgr.process_pricing_audit_webhook({"Company": "VC01"}) + 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: + """PricingResultSyncAdapter:校验、转换、落库。""" + + def test_validate_requires_all_fields(self): + adapter = PricingResultSyncAdapter() + assert adapter.validate({}) is False + base = _pricing_audit_payload("X1") + assert adapter.validate(base) is True + assert adapter.validate({**base, "Company": ""}) is False + + def test_transform_maps_eip_fields(self): + adapter = PricingResultSyncAdapter() + data = { + "Company": "VC01", + "eipAuditNo": "EIP-1", + "purchase_category": "2", + "inquiryNo": "RFQ99", + "auditStatus": "APPROVED", + "auditUser": "u1", + "auditTimestamp": "2026-01-02T08:30:00", + } + t = adapter.transform_to_local(data) + assert t["company"] == "VC01" + assert t["eip_audit_no"] == "EIP-1" + assert t["purchase_category"] == 2 + assert t["inquiry_no"] == "RFQ99" + assert t["audit_status"] == "APPROVED" + assert t["external_id"] == "RFQ99_EIP-1" + + def test_push_to_local_approved_updates_inquiry(self): + inq_no = _unique_inquiry_no() + _inquiry_price_audit_pending(inquiry_no=inq_no) + adapter = PricingResultSyncAdapter() + op = adapter.push_to_local(_pricing_audit_payload(inq_no, "APPROVED")) + assert op.status == SyncStatus.SUCCESS + obj = Inquiry.objects.get(inquiry_no=inq_no) + assert obj.status == 8 + assert obj.approval_status == 1 + + def test_push_to_local_rejected_sets_lost(self): + inq_no = _unique_inquiry_no() + _inquiry_price_audit_pending(inquiry_no=inq_no) + adapter = PricingResultSyncAdapter() + op = adapter.push_to_local(_pricing_audit_payload(inq_no, "REJECTED")) + assert op.status == SyncStatus.SUCCESS + obj = Inquiry.objects.get(inquiry_no=inq_no) + assert obj.status == 9 + assert obj.approval_status == 2 + + def test_push_to_local_fails_when_not_price_audit_status(self): + inq_no = _unique_inquiry_no() + _inquiry_price_audit_pending(inquiry_no=inq_no, status=5) + adapter = PricingResultSyncAdapter() + op = adapter.push_to_local(_pricing_audit_payload(inq_no)) + assert op.status == SyncStatus.FAILED + @pytest.mark.django_db class TestMiscMaterialSyncAPI: - """POST /api/pisadmin/sync/misc-material/(DRF force_authenticate 绕过 AK/SK 验签,仅测业务链)。""" + """POST /api/sync/material/misc(DRF force_authenticate 绕过 AK/SK 验签,仅测业务链)。""" - URL = "/api/pisadmin/sync/misc-material/" + URL = "/api/sync/material/misc" def test_post_success(self, api_client, admin_user): api_client.force_authenticate(user=admin_user) @@ -146,6 +337,9 @@ class TestMiscMaterialSyncAPI: "unit": "KG", } 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"] @@ -153,32 +347,264 @@ class TestMiscMaterialSyncAPI: def test_post_validation_error(self, api_client, admin_user): api_client.force_authenticate(user=admin_user) - response = api_client.post( - self.URL, data={"companyCode": ""}, format="json" - ) + 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_unauthenticated(self, api_client): - response = api_client.post( - self.URL, - data={ - "companyCode": "VC01", - "materialCode": "X", - "materialName": "n", - "specification": "", - "unit": "", - }, - format="json", - ) - assert response.status_code in ( - status.HTTP_401_UNAUTHORIZED, - status.HTTP_403_FORBIDDEN, + def test_post_allow_any_without_login(self, api_client): + """EIP 抛转接口当前为 AllowAny:未携带 JWT 也应能调通(与生产验签策略无关时)。""" + payload = { + "companyCode": "VC01", + "materialCode": "NOAUTH-001", + "materialName": "n", + "specification": "", + "unit": "", + } + 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 MiscProcMaterial.objects.filter(partid="NOAUTH-001").exists() @pytest.mark.django_db -def test_sync_factory_registers_misc_material(): - assert "misc_material" in SyncFactory.list_adapters() - adapter = SyncFactory.create("misc_material") - assert isinstance(adapter, MiscMaterialSyncAdapter) +class TestPricingAuditResultSyncAPI: + """POST /api/pricing/applications/result""" + + URL = "/api/pricing/applications/result" + + def test_post_success_approved(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + inq_no = _unique_inquiry_no() + _inquiry_price_audit_pending(inquiry_no=inq_no) + payload = _pricing_audit_payload(inq_no) + response = api_client.post(self.URL, data=payload, format="json") + _log_http_roundtrip( + "核价审核结果抛转 · 审核通过 APPROVED", self.URL, payload, response + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["Status"] is True + assert "审核结果已接收" in response.data["Message"] + assert Inquiry.objects.get(inquiry_no=inq_no).status == 8 + + def test_post_validation_error(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + bad = {"Company": ""} + 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"] is False + + def test_post_business_error_wrong_status(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + inq_no = _unique_inquiry_no() + _inquiry_price_audit_pending(inquiry_no=inq_no, status=5) + payload = _pricing_audit_payload(inq_no) + response = api_client.post(self.URL, data=payload, format="json") + _log_http_roundtrip( + "核价审核结果抛转 · 业务失败(询价单非价格审核)", self.URL, payload, response + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["Status"] is False + + +@pytest.mark.django_db +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() diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 129c0e1..00ec373 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -121,7 +121,7 @@ router.beforeEach(async (to, from, next) => { NProgress.configure({showSpinner: false}); if (to.meta.title) NProgress.start(); const token = Session.get('token'); - if ((to.path === '/login' || to.path === '/login/supplier') && !token) { + if ((to.path === '/login' || to.path === '/login/supplier' || to.meta.public) && !token) { next(); NProgress.done(); } else { diff --git a/web/src/router/route.ts b/web/src/router/route.ts index ed39d77..3dc8b85 100644 --- a/web/src/router/route.ts +++ b/web/src/router/route.ts @@ -125,5 +125,25 @@ export const staticRoutes: Array = [ meta: { title: 'message.router.personal' }, - } + }, + /** 杂采比价对外分享(免登录,`?inquiry_id=`;与 comparePrice 共用组件) */ + { + path: '/public/misc-compare', + name: 'PublicMiscComparePrice', + component: () => import('/@/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue'), + meta: { + title: '比价信息', + public: true, + }, + }, + /** 比价分享内跳转报价单详情(免登录,`?id=` 报价 autoid;与供应商 quotation detail 共用组件) */ + { + path: '/public/pissupplier/quotation', + name: 'PublicPissupplierQuotationShare', + component: () => import('/@/views/pissupplier/quotation/detail.vue'), + meta: { + title: '查看报价', + public: true, + }, + }, ]; diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index a39f5f4..c1654de 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -1,9 +1,17 @@ +import axios from 'axios' import { request } from '/@/utils/service' +import { getBaseURL } from '/@/utils/baseUrl' const baseUrl = '/api/pisadmin/miscprocurement/inquiry/' /** 列表/增删改 API;完整表单在隐藏路由 `PisadminRfqMiscInquiryDetail`(`miscInquiryDetail/:id`)。议价相关接口由隐藏路由 `PisadminRfqMiscComparePrice`(`comparePrice/:id`)使用。 */ +/** 免登录:比价数据包,``inquiry_id`` 为询价单主键 */ +export const fetchPublicComparisonBundle = async (params: { inquiry_id: string | number }) => { + const url = `${getBaseURL().replace(/\/$/, '')}/api/public/miscprocurement/comparison/` + return axios.get(url, { params: { inquiry_id: String(params.inquiry_id) } }) +} + export const GetList = (params: any) => request({ url: baseUrl, method: 'get', params }) export const GetObj = (id: string | number) => request({ url: baseUrl + id + '/', method: 'get' }) export const AddObj = (data: any) => request({ url: baseUrl, method: 'post', data }) diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue index 782fa06..468386b 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue @@ -1,7 +1,7 @@