From 66bd9e0db021fcfb8803b48dc640dca6e9477c6c Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 04:33:14 +0800 Subject: [PATCH 01/26] feat(product_change): add schema for product change requests with validation --- .../product_change_request_schema_v2.py | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/backend/app/schemas/product_change_request_schema_v2.py diff --git a/src/backend/app/schemas/product_change_request_schema_v2.py b/src/backend/app/schemas/product_change_request_schema_v2.py new file mode 100644 index 0000000..acf4200 --- /dev/null +++ b/src/backend/app/schemas/product_change_request_schema_v2.py @@ -0,0 +1,175 @@ +# src/backend/app/schemas/product_change_request_schema_v2.py +from pydantic import BaseModel, Field, model_validator # model_validator for Pydantic V2 +from typing import Optional, List, Dict, Any +from decimal import Decimal +import datetime +from enum import Enum + +from typing_extensions import Self + + +class ProductStatusApiEnum(str, Enum): + """商品状态枚举""" + ACTIVE = "ACTIVE" + INACTIVE_BY_MERCHANT = "INACTIVE_BY_MERCHANT" + SUSPENDED_BY_ADMIN = "SUSPENDED_BY_ADMIN" + DISCONTINUED = "DISCONTINUED" + + +class ProductChangeRequestTypeApiEnum(str, Enum): + """商品变更请求类型枚举""" + PRODUCT_CREATE = "PRODUCT_CREATE" + PRODUCT_UPDATE = "PRODUCT_UPDATE" + PRODUCT_DELETE = "PRODUCT_DELETE" + + +class ProductChangeRequestStatusApiEnum(str, Enum): + """商品变更请求状态枚举""" + PENDING_APPROVAL = "PENDING_APPROVAL" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + APPLIED = "APPLIED" + CANCELLED_BY_USER = "CANCELLED_BY_USER" + + +# --- 合并后的建议商品数据 Schema --- +class ProposedProductData(BaseModel): + """ + 用于商品创建或更新时,在 ProposedData_JSON 中建议的商品数据。 + - 对于 PRODUCT_CREATE: ProductName, Price, CategoryID, StockQuantity 通常是必需的 (服务层校验)。 + - 对于 PRODUCT_UPDATE: 所有字段都是可选的,表示要更新的商品属性。 + - 对于 PRODUCT_DELETE: 该对象通常为空或未提供。 + """ + ProductName: Optional[str] = Field(None, max_length=255, description="商品名称。对于创建请求是必需的。") + ProductDescription: Optional[str] = Field(None, description="商品详细介绍。对于创建请求可选。") + Price: Optional[Decimal] = Field(None, gt=Decimal(0), description="单价,如果提供则必须大于0。对于创建请求是必需的。") + ProductStatus: Optional[ProductStatusApiEnum] = Field(None, + description="商品状态。对于商家可以是ACTIVE,INACTIVE_BY_MERCHANT,DISCONTINUED。对于创建请求可选。") + CategoryID: Optional[int] = Field(None, description="所属的种类ID。对于创建请求是必需的。") + StockQuantity: Optional[int] = Field(None, ge=0, description="库存数量, 如果提供则必须大于等于0。对于创建请求可选。") + MainImageURL: Optional[str] = Field(None, max_length=512, description="商品主图片地址。可选。") + + +# --- ProductChangeRequest 相关 Schemas --- + +class ProductChangeRequestBase(BaseModel): + """商品变更请求的基础模型。""" + ProductID: Optional[int] = Field(None, description="如果是 PRODUCT_UPDATE 或 PRODUCT_DELETE,则提供商品ID。") + StoreID: int = Field(..., description="商品所属的店铺ID。对于所有请求都是必需的。") + RequestType: ProductChangeRequestTypeApiEnum = Field(..., description="请求类型(创建商品、更新商品或删除商品)。") + SubmitterNotes: Optional[str] = Field(None, description="商家提交备注。") + + +class ProductChangeRequestCreate(BaseModel): + """ + 用于 API 创建新的“商品变更请求”。 + MerchantUserID 通常从请求上下文中获取。 + """ + ProposedData_JSON: Optional[ProposedProductData] = Field(None, + description="提供的商品相关字段。会根据 RequestType 校验其内容。" + "如果 RequestType 是 PRODUCT_CREATE,则必须提供所有基础字段。" + "如果 RequestType 是 PRODUCT_UPDATE,则所有字段都是可选的。" + "如果 RequestType 是 PRODUCT_DELETE,则不应提交该字段。") + + @model_validator(mode='after') # Pydantic V2 after validator + def check_on_request_type(self) -> Self: + """ + 在字段已填充后进行校验: + - 如果 RequestType 是 PRODUCT_UPDATE 或 PRODUCT_DELETE,则 ProductID 必须提供。 + - 如果 RequestType 是 PRODUCT_CREATE,则 ProductID 必须为 None 或未提供。 + """ + request_type = self.RequestType # 访问已填充的字段 + product_id = self.ProductID + + if request_type == ProductChangeRequestTypeApiEnum.PRODUCT_CREATE: + # 不包含 ProductID + if product_id is not None: raise ValueError("ProductID must be null when RequestType is PRODUCT_CREATE.") + # 必须提供新商品的所有基础字段 + if self.ProposedData_JSON is None: + raise ValueError( + "ProposedData_JSON must be provided and be a dictionary when RequestType is PRODUCT_CREATE.") + # 所有基础字段必须存在,这个检查在服务层进行 + + elif request_type == ProductChangeRequestTypeApiEnum.PRODUCT_UPDATE: + # 必须提供 ProductID + if product_id is None: + raise ValueError("ProductID must be provided when RequestType is PRODUCT_UPDATE.") + # 必须提供 ProposedData_JSON + if self.ProposedData_JSON is None: + raise ValueError( + "ProposedData_JSON must be provided and be a dictionary when RequestType is PRODUCT_UPDATE.") + + elif request_type == ProductChangeRequestTypeApiEnum.PRODUCT_DELETE: + # 必须提供 ProductID + if product_id is None: + raise ValueError("ProductID must be provided when RequestType is PRODUCT_DELETE.") + # ProposedData_JSON 必须为 None 或未提供 + if self.ProposedData_JSON is not None: + raise ValueError("ProposedData_JSON must be null when RequestType is PRODUCT_DELETE.") + else: + # not reachable + raise ValueError("Invalid RequestType. Must be PRODUCT_CREATE, PRODUCT_UPDATE, or PRODUCT_DELETE.") + + return self + + +class ProductChangeRequestResponse(ProductChangeRequestBase): + """ + API 返回单个商品变更请求信息时使用的数据模型。 + """ + # override ProductID + ProductID: int = Field(..., description="商品ID。") + + ChangeRequestID: int = Field(..., description="变更请求的唯一ID。") + MerchantUserID: int = Field(..., description="提交请求的商家UserID。") + ProposedData_JSON: Optional[Dict[str, Any]] = Field(None, description="建议的数据体 (原始JSON)。") + Status: ProductChangeRequestStatusApiEnum = Field(..., description="请求的当前状态。") + AdminReviewerID: Optional[int] = Field(None, description="审核请求的管理员UserID。") + ReviewTimestamp: Optional[datetime.datetime] = Field(None, description="审核时间。") + AdminNotes: Optional[str] = Field(None, description="管理员审核备注。") + CreationTime: datetime.datetime = Field(..., description="请求创建时间。") + LastUpdatedDate: datetime.datetime = Field(..., description="请求最后更新时间。") + + +class ProductChangeRequestListResponse(BaseModel): + """ + API 返回商品变更请求列表时使用的数据模型。 + """ + Requests: List[ProductChangeRequestResponse] = Field(..., description="商品变更请求列表。") + TotalCount: int = Field(..., description="符合条件的请求总数。") + + +# --- 用于更新请求的 Schemas --- + +# 商家删除请求改为发送DELETE请求,不使用新的Schema + +class ProductChangeRequestUpdateByAdmin(BaseModel): + """ + 管理员审核并更新变更请求时发送的数据。 + """ + Status: ProductChangeRequestStatusApiEnum = Field(..., description="管理员设置的新状态 (例如 APPROVED, REJECTED)。") + AdminNotes: Optional[str] = Field(None, description="管理员审核备注。") + + @model_validator(mode='after') # Pydantic V2 after validator + def check_status(self) -> Self: + """ + 检查管理员是否只能将状态设置为 'APPROVED' 或 'REJECTED'。 + """ + if self.Status not in [ProductChangeRequestStatusApiEnum.APPROVED, + ProductChangeRequestStatusApiEnum.REJECTED]: + raise ValueError("Admin can only set Status to 'APPROVED' or 'REJECTED'.") + return self + + +# --- 用于 API 查询参数的 Schema --- +class ProductChangeRequestQueryParams(BaseModel): + """ + 用于 API 端点查询商品变更请求的参数模型。 + 将与 Depends() 一起使用。所有字段都是可选的查询参数。 + """ + Status: Optional[List[ProductChangeRequestStatusApiEnum]] = Field(default=None, + description="按一个或多个状态筛选,例如 ['PENDING_APPROVAL', 'APPROVED']") + RequestType: Optional[ProductChangeRequestTypeApiEnum] = Field(default=None, description="按请求类型筛选") + StoreID: Optional[int] = Field(default=None, description="按店铺ID筛选") + MerchantUserID: Optional[int] = Field(default=None, description="按商家ID筛选") + ProductID: Optional[int] = Field(default=None, description="按商品ID筛选") From cda2c2f6353cdee2bdcb5cebdb72032709bb37ae Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 05:23:03 +0800 Subject: [PATCH 02/26] feat(product_change): declare CRUD operations for product change requests --- .../crud/product_change_request_crud_v2.py | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/backend/app/crud/product_change_request_crud_v2.py diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py new file mode 100644 index 0000000..a8fc172 --- /dev/null +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -0,0 +1,190 @@ +# src/backend/app/crud/product_change_request_crud_v2.py +from typing import Optional, List, Dict, Any +from sqlalchemy import Connection, text, exc # 导入 exc 用于异常处理 +from loguru import logger + +# from backend.app.schemas.product_change_request_schema_v2 import ( +# ProductChangeRequestTypeApiEnum, +# ProductChangeRequestStatusApiEnum, +# ) + + +class ProductChangeRequestCRUD2: + """ + 商品变更请求的 CRUD 操作类。 + """ + __instance: Optional["ProductChangeRequestCRUD2"] = None + + @classmethod + def get_instance(cls) -> "ProductChangeRequestCRUD2": + """ + 获取单例实例 + :return: ProductChangeRequestCRUD实例 + """ + if cls.__instance is None: + cls.__instance = ProductChangeRequestCRUD2() + return cls.__instance + + def __init__(self): + self.table_name = "ProductChangeRequest" + + @staticmethod + def _set_actor_session_variable(conn: Connection, actor_id: Optional[int]): + """ + 设置会话变量,记录操作者信息 + :param conn: 数据库连接 + :param actor_id: 操作者ID + """ + if actor_id is not None: + conn.execute( + text("SET @actor_id = :actor_id"), + {"actor_id": actor_id} + ) + else: + conn.execute( + text("SET @actor_id = NULL") + ) + + def get_request_by_id(self, conn: Connection, *, request_id: int) -> Optional[Dict[str, Any]]: + """ + 根据请求ID获取商品变更请求 + :param conn: 数据库连接 + :param request_id: 商品变更请求ID + :return: 商品变更请求数据字典或None + """ + raise NotImplementedError + + def get_request_by_id_for_owner(self, conn: Connection, *, request_id: int, merchant_user_id: int) -> Optional[ + Dict[str, Any]]: + """ + 根据请求ID和商家ID获取商品变更请求 + :param conn: 数据库连接 + :param request_id: 商品变更请求ID + :param merchant_user_id: 商家ID + :return: 商品变更请求数据字典或None + """ + raise NotImplementedError + + def get_request_list( + self, + conn: Connection, + *, + status: Optional[str] = None, + request_type: Optional[str] = None, + store_id: Optional[int] = None, + product_id: Optional[int] = None, + merchant_user_id: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """ + 获取商品变更请求列表,可按状态、类型、商店ID、商品ID和商家ID筛选 + :param conn: 数据库连接 + :param status: 商品变更请求状态 + :param request_type: 商品变更请求类型 + :param store_id: 商店ID + :param product_id: 商品ID + :param merchant_user_id: 商家ID + :return: 商品变更请求列表 + """ + raise NotImplementedError + + def create_request_create_product( + self, + conn: Connection, + *, + store_id: int, + submitter_notes: Optional[str], + proposed_data_json: Dict[str, Any], + actor_id: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 创建商品变更请求 - 创建商品 + :param conn: 数据库连接 + :param store_id: 商店ID + :param submitter_notes: 提交者备注 + :param proposed_data_json: 建议数据JSON。不检查数据合法性 + :param actor_id: 操作者ID + :return: 商品变更请求数据字典 + """ + raise NotImplementedError + + def create_request_update_product( + self, + conn: Connection, + *, + product_id: int, + store_id: int, + submitter_notes: Optional[str], + proposed_data_json: Dict[str, Any], + actor_id: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 创建商品变更请求 - 更新商品 + :param conn: 数据库连接 + :param product_id: 商品ID + :param store_id: 商店ID + :param submitter_notes: 提交者备注 + :param proposed_data_json: 建议数据JSON。不检查数据合法性 + :param actor_id: 操作者ID + :return: 商品变更请求数据字典 + """ + raise NotImplementedError + + def create_request_delete_product( + self, + conn: Connection, + *, + product_id: int, + store_id: int, + submitter_notes: Optional[str], + actor_id: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 创建商品变更请求 - 删除商品 + :param conn: 数据库连接 + :param product_id: 商品ID + :param store_id: 商店ID + :param submitter_notes: 提交者备注 + :param actor_id: 操作者ID + :return: 商品变更请求数据字典 + """ + raise NotImplementedError + + def delete_request( + self, + conn: Connection, + *, + request_id: int, + merchant_user_id: int, + actor_id: Optional[int] = None, + ) -> bool: + """ + 删除商品变更请求(商家设置为已取消状态) + :param conn: 数据库连接 + :param request_id: 商品变更请求ID + :param merchant_user_id: 商家ID + :param actor_id: 操作者ID(通常为商家ID) + :return: 是否删除成功 + """ + raise NotImplementedError + + def update_request_by_admin( + self, + conn: Connection, + *, + request_id: int, + status: str, + admin_notes: Optional[str], + admin_reviewer_id: int, + actor_id: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 管理员审核商品变更请求 + :param conn: 数据库连接 + :param request_id: 商品变更请求ID + :param status: 商品变更请求状态 + :param admin_notes: 管理员备注 + :param admin_reviewer_id: 管理员ID + :param actor_id: 操作者ID(通常为管理员ID) + :return: 商品变更请求数据字典 + """ + raise NotImplementedError From 6d10483c08f921e6973428340119b6c0df589e80 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 05:51:52 +0800 Subject: [PATCH 03/26] feat(product_change): implement CRUD operations --- .../crud/product_change_request_crud_v2.py | 390 +++++++++++++++--- .../product_change_request_service_v2.py | 0 2 files changed, 341 insertions(+), 49 deletions(-) create mode 100644 src/backend/app/services/product_change_request_service_v2.py diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py index a8fc172..c1dbb43 100644 --- a/src/backend/app/crud/product_change_request_crud_v2.py +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -1,13 +1,13 @@ # src/backend/app/crud/product_change_request_crud_v2.py from typing import Optional, List, Dict, Any -from sqlalchemy import Connection, text, exc # 导入 exc 用于异常处理 +from sqlalchemy import Connection, text, exc from loguru import logger +import json # For explicit JSON handling if needed +import datetime -# from backend.app.schemas.product_change_request_schema_v2 import ( -# ProductChangeRequestTypeApiEnum, -# ProductChangeRequestStatusApiEnum, -# ) +# 假设这些枚举在您的 schema 文件中定义并可导入 +# from backend.app.schemas.product_change_request_schema_v2 import ProductChangeRequestTypeApiEnum, ProductChangeRequestStatusApiEnum class ProductChangeRequestCRUD2: """ @@ -19,7 +19,7 @@ class ProductChangeRequestCRUD2: def get_instance(cls) -> "ProductChangeRequestCRUD2": """ 获取单例实例 - :return: ProductChangeRequestCRUD实例 + :return: ProductChangeRequestCRUD2实例 """ if cls.__instance is None: cls.__instance = ProductChangeRequestCRUD2() @@ -27,6 +27,7 @@ def get_instance(cls) -> "ProductChangeRequestCRUD2": def __init__(self): self.table_name = "ProductChangeRequest" + logger.info(f"{self.__class__.__name__} initialized for table {self.table_name}.") @staticmethod def _set_actor_session_variable(conn: Connection, actor_id: Optional[int]): @@ -45,6 +46,24 @@ def _set_actor_session_variable(conn: Connection, actor_id: Optional[int]): text("SET @actor_id = NULL") ) + @staticmethod + def _deserialize_proposed_data(data: Optional[Any]) -> Optional[Dict[str, Any]]: + """辅助方法:如果 ProposedData_JSON 是字符串,则反序列化为字典。""" + if isinstance(data, str): + try: + return json.loads(data) + except json.JSONDecodeError: + logger.warning(f"Failed to decode ProposedData_JSON string: {data}") + return None # 或者返回原始字符串,或抛出错误 + return data # 假设已经是字典或 None + + @staticmethod + def _serialize_proposed_data(data: Optional[Dict[str, Any]]) -> Optional[str]: + """辅助方法:将 ProposedData_JSON 字典序列化为字符串。""" + if isinstance(data, dict): + return json.dumps(data) + return data # 已经是字符串或 None + def get_request_by_id(self, conn: Connection, *, request_id: int) -> Optional[Dict[str, Any]]: """ 根据请求ID获取商品变更请求 @@ -52,7 +71,24 @@ def get_request_by_id(self, conn: Connection, *, request_id: int) -> Optional[Di :param request_id: 商品变更请求ID :return: 商品变更请求数据字典或None """ - raise NotImplementedError + logger.debug(f"Getting ProductChangeRequest by ID {request_id}") + select_stmt = text(f""" + SELECT ChangeRequestID, ProductID, MerchantUserID, StoreID, RequestType, + ProposedData_JSON, Status, SubmitterNotes, AdminReviewerID, + ReviewTimestamp, AdminNotes, CreationTime, LastUpdatedDate + FROM {self.table_name} + WHERE ChangeRequestID = :ChangeRequestID + """) + try: + result = conn.execute(select_stmt, {"ChangeRequestID": request_id}).fetchone() + if result: + row_dict = dict(result._mapping) # type: ignore + row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) + return row_dict + return None + except Exception as e: + logger.error(f"Error getting ProductChangeRequest by ID {request_id}: {e}") + return None def get_request_by_id_for_owner(self, conn: Connection, *, request_id: int, merchant_user_id: int) -> Optional[ Dict[str, Any]]: @@ -63,128 +99,384 @@ def get_request_by_id_for_owner(self, conn: Connection, *, request_id: int, merc :param merchant_user_id: 商家ID :return: 商品变更请求数据字典或None """ - raise NotImplementedError + logger.debug(f"Getting ProductChangeRequest by ID {request_id} for MerchantUserID {merchant_user_id}") + select_stmt = text(f""" + SELECT ChangeRequestID, ProductID, MerchantUserID, StoreID, RequestType, + ProposedData_JSON, Status, SubmitterNotes, AdminReviewerID, + ReviewTimestamp, AdminNotes, CreationTime, LastUpdatedDate + FROM {self.table_name} + WHERE ChangeRequestID = :ChangeRequestID AND MerchantUserID = :MerchantUserID + """) + try: + result = conn.execute(select_stmt, + {"ChangeRequestID": request_id, "MerchantUserID": merchant_user_id}).fetchone() + if result: + row_dict = dict(result._mapping) # type: ignore + row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) + return row_dict + return None + except Exception as e: + logger.error( + f"Error getting ProductChangeRequest ID {request_id} for MerchantUserID {merchant_user_id}: {e}") + return None def get_request_list( self, conn: Connection, *, - status: Optional[str] = None, + status: Optional[List[str] | str] = None, request_type: Optional[str] = None, store_id: Optional[int] = None, product_id: Optional[int] = None, merchant_user_id: Optional[int] = None, + # 分页参数可以后续添加,当前按用户要求不分页 + # offset: int = 0, + # limit: int = 1000 # 默认一个较大的限制,如果真的不分页 ) -> List[Dict[str, Any]]: """ - 获取商品变更请求列表,可按状态、类型、商店ID、商品ID和商家ID筛选 + 获取商品变更请求列表,可按状态、类型、商店ID、商品ID和商家ID筛选。 + 注意:如果不过滤,可能会返回大量数据。 :param conn: 数据库连接 - :param status: 商品变更请求状态 + :param status: 商品变更请求状态列表或单个状态 :param request_type: 商品变更请求类型 :param store_id: 商店ID :param product_id: 商品ID :param merchant_user_id: 商家ID - :return: 商品变更请求列表 """ - raise NotImplementedError + logger.debug( + f"Getting ProductChangeRequest list with filters - Status: {status}, Type: {request_type}, Store: {store_id}, Product: {product_id}, Merchant: {merchant_user_id}") + + params: Dict[str, Any] = {} + where_clauses: List[str] = [] + + if status: + # 如果 status 是列表,需要生成 IN (:status_1, :status_2, ...) + if isinstance(status, list) and len(status) > 0: + status_placeholders = [] + for i, s_val in enumerate(status): + param_name = f"status_{i}" + status_placeholders.append(f":{param_name}") + params[param_name] = s_val + where_clauses.append(f"Status IN ({', '.join(status_placeholders)})") + elif isinstance(status, str): # 兼容单个状态字符串的情况 + where_clauses.append("Status = :Status_filter") + params["Status_filter"] = status + + if request_type: + where_clauses.append("RequestType = :RequestType") + params["RequestType"] = request_type + if store_id is not None: + where_clauses.append("StoreID = :StoreID") + params["StoreID"] = store_id + if product_id is not None: + where_clauses.append("ProductID = :ProductID") + params["ProductID"] = product_id + if merchant_user_id is not None: + where_clauses.append("MerchantUserID = :MerchantUserID") + params["MerchantUserID"] = merchant_user_id + + where_sql = "" + if where_clauses: + where_sql = "WHERE " + " AND ".join(where_clauses) + + # DDL 默认按 CreationTime, LastUpdatedDate 排序,这里可以加一个显式排序 + # ORDER BY CreationTime DESC LIMIT :Limit OFFSET :Offset (如果分页) + select_stmt = text(f""" + SELECT ChangeRequestID, ProductID, MerchantUserID, StoreID, RequestType, + ProposedData_JSON, Status, SubmitterNotes, AdminReviewerID, + ReviewTimestamp, AdminNotes, CreationTime, LastUpdatedDate + FROM {self.table_name} + {where_sql} + ORDER BY CreationTime DESC + """) + # params["Limit"] = limit + # params["Offset"] = offset + + try: + results = conn.execute(select_stmt, params).fetchall() + processed_results = [] + for row in results: + row_dict = dict(row._mapping) # type: ignore + row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) + processed_results.append(row_dict) + return processed_results + except Exception as e: + logger.error(f"Error getting ProductChangeRequest list: {e}") + return [] + + def _create_generic_request( + self, + conn: Connection, + *, + merchant_user_id: int, + store_id: int, + request_type: str, # ProductChangeRequestTypeApiEnum.value + proposed_data_json: Optional[Dict[str, Any]], + submitter_notes: Optional[str], + product_id: Optional[int], + actor_id: Optional[int] + ) -> Optional[Dict[str, Any]]: + """内部辅助方法,用于创建不同类型的请求。""" + self._set_actor_session_variable(conn, actor_id) + + # CreationTime 和 LastUpdatedDate 由数据库 DEFAULT CURRENT_TIMESTAMP 处理 + # Status 由数据库 DEFAULT 'PENDING_APPROVAL' 处理 + insert_stmt = text(f""" + INSERT INTO {self.table_name} ( + ProductID, MerchantUserID, StoreID, RequestType, ProposedData_JSON, + SubmitterNotes + -- Status, CreationTime, LastUpdatedDate are handled by DB defaults + ) VALUES ( + :ProductID, :MerchantUserID, :StoreID, :RequestType, :ProposedData_JSON, + :SubmitterNotes + ) + """) + + serialized_proposed_data = self._serialize_proposed_data(proposed_data_json) + + try: + result = conn.execute(insert_stmt, { + "ProductID": product_id, + "MerchantUserID": merchant_user_id, + "StoreID": store_id, + "RequestType": request_type, + "ProposedData_JSON": serialized_proposed_data, + "SubmitterNotes": submitter_notes + }) + new_request_id = result.lastrowid + if new_request_id is None: + logger.warning( + f"lastrowid not available after creating {request_type} request for MerchantID {merchant_user_id}.") + return None + + logger.info(f"{request_type} request created with ID {new_request_id} by ActorID {actor_id}.") + return self.get_request_by_id(conn, request_id=new_request_id) # actor_id for get is optional + except exc.IntegrityError as e: + logger.error(f"Integrity error creating {request_type} request: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error creating {request_type} request: {e}") + return None def create_request_create_product( self, conn: Connection, *, + merchant_user_id: int, # 从认证用户获取 store_id: int, submitter_notes: Optional[str], - proposed_data_json: Dict[str, Any], + proposed_data_json: Dict[str, Any], # 服务层应确保此字典符合 ProposedProductData for create actor_id: Optional[int] = None, - ) -> Dict[str, Any]: + ) -> Optional[Dict[str, Any]]: """ - 创建商品变更请求 - 创建商品 + 创建商品变更请求 - 创建新商品 + 本方法不检查 ProposedData_JSON 的内容是否符合要求,假设服务层会进行验证。 :param conn: 数据库连接 + :param merchant_user_id: 商家用户ID :param store_id: 商店ID :param submitter_notes: 提交者备注 - :param proposed_data_json: 建议数据JSON。不检查数据合法性 - :param actor_id: 操作者ID - :return: 商品变更请求数据字典 + :param proposed_data_json: 建议数据体(字典格式) + :param actor_id: 操作者ID,默认为商家用户ID + :return: 创建的请求数据字典或None """ - raise NotImplementedError + logger.info( + f"ActorID {actor_id} creating PRODUCT_CREATE request for MerchantID {merchant_user_id}, StoreID {store_id}") + return self._create_generic_request( + conn=conn, + merchant_user_id=merchant_user_id, + store_id=store_id, + request_type="PRODUCT_CREATE", # 直接使用字符串值 + proposed_data_json=proposed_data_json, + submitter_notes=submitter_notes, + product_id=None, # ProductID 为空对于创建请求 + actor_id=actor_id if actor_id is not None else merchant_user_id + ) def create_request_update_product( self, conn: Connection, *, product_id: int, + merchant_user_id: int, # 从认证用户获取 store_id: int, submitter_notes: Optional[str], - proposed_data_json: Dict[str, Any], + proposed_data_json: Dict[str, Any], # 服务层应确保此字典符合 ProposedProductData for update actor_id: Optional[int] = None, - ) -> Dict[str, Any]: + ) -> Optional[Dict[str, Any]]: """ - 创建商品变更请求 - 更新商品 + 创建商品变更请求 - 更新现有商品 :param conn: 数据库连接 :param product_id: 商品ID + :param merchant_user_id: 商家用户ID :param store_id: 商店ID :param submitter_notes: 提交者备注 - :param proposed_data_json: 建议数据JSON。不检查数据合法性 - :param actor_id: 操作者ID - :return: 商品变更请求数据字典 + :param proposed_data_json: 建议数据体(字典格式),服务层应确保此字典符合更新商品的要求 + :param actor_id: 操作者ID,默认为商家用户ID + :return: 创建的请求数据字典或None """ - raise NotImplementedError + logger.info( + f"ActorID {actor_id} creating PRODUCT_UPDATE request for ProductID {product_id}, MerchantID {merchant_user_id}") + return self._create_generic_request( + conn=conn, + merchant_user_id=merchant_user_id, + store_id=store_id, + request_type="PRODUCT_UPDATE", + proposed_data_json=proposed_data_json, + submitter_notes=submitter_notes, + product_id=product_id, + actor_id=actor_id if actor_id is not None else merchant_user_id + ) def create_request_delete_product( self, conn: Connection, *, product_id: int, + merchant_user_id: int, # 从认证用户获取 store_id: int, submitter_notes: Optional[str], actor_id: Optional[int] = None, - ) -> Dict[str, Any]: + ) -> Optional[Dict[str, Any]]: """ 创建商品变更请求 - 删除商品 :param conn: 数据库连接 :param product_id: 商品ID + :param merchant_user_id: 商家用户ID :param store_id: 商店ID :param submitter_notes: 提交者备注 - :param actor_id: 操作者ID - :return: 商品变更请求数据字典 + :param actor_id: 操作者ID,默认为商家用户ID + :return: 创建的请求数据字典或None """ - raise NotImplementedError + logger.info( + f"ActorID {actor_id} creating PRODUCT_DELETE request for ProductID {product_id}, MerchantID {merchant_user_id}") + return self._create_generic_request( + conn=conn, + merchant_user_id=merchant_user_id, + store_id=store_id, + request_type="PRODUCT_DELETE", + proposed_data_json=None, # 删除请求通常不需要建议数据体 + submitter_notes=submitter_notes, + product_id=product_id, + actor_id=actor_id if actor_id is not None else merchant_user_id + ) - def delete_request( + def delete_request( # This method now means "cancel by merchant" self, conn: Connection, *, request_id: int, - merchant_user_id: int, + # merchant_user_id: int, # Ownership check done by service layer actor_id: Optional[int] = None, ) -> bool: """ - 删除商品变更请求(商家设置为已取消状态) + 取消商品变更请求 - 由商家发起 (或管理员代为操作,但主要场景是商家)。 + 将状态更新为 'CANCELLED_BY_USER',仅当当前状态为 'PENDING_APPROVAL'。 + 权限检查(例如,确保 actor_id 是该请求的 MerchantUserID)应由服务层完成。 + :param conn: 数据库连接 :param request_id: 商品变更请求ID - :param merchant_user_id: 商家ID - :param actor_id: 操作者ID(通常为商家ID) - :return: 是否删除成功 + :param actor_id: 操作者ID(应当是商家用户ID) + :return: 若该请求的状态成功更新为 CANCELLED_BY_USER,则返回 True,否则返回 False。 """ - raise NotImplementedError + logger.info(f"ActorID {actor_id} attempting to cancel (delete_request) ChangeRequestID {request_id}.") + self._set_actor_session_variable(conn, actor_id) + + update_stmt = text(f""" + UPDATE {self.table_name} + SET Status = :cancelled_status + WHERE ChangeRequestID = :request_id AND Status = :pending_status + """) + # LastUpdatedDate 会由数据库的 ON UPDATE CURRENT_TIMESTAMP 自动处理 + + try: + # Assuming ProductChangeRequestStatusApiEnum is available or using string literals + # from backend.app.schemas.product_change_request_schema_v2 import ProductChangeRequestStatusApiEnum + # cancelled_status = ProductChangeRequestStatusApiEnum.CANCELLED_BY_USER.value + # pending_status = ProductChangeRequestStatusApiEnum.PENDING_APPROVAL.value + cancelled_status = "CANCELLED_BY_USER" + pending_status = "PENDING_APPROVAL" + + result = conn.execute(update_stmt, { + "cancelled_status": cancelled_status, + "request_id": request_id, + "pending_status": pending_status + }) + + if result.rowcount > 0: + logger.info(f"ChangeRequestID {request_id} status updated to {cancelled_status} by ActorID {actor_id}.") + return True + else: + # No rows updated could mean: + # 1. Request ID does not exist. + # 2. Request ID exists but its status was not 'PENDING_APPROVAL'. + # Service layer might want to fetch the request to give a more specific reason. + logger.warning( + f"Failed to cancel ChangeRequestID {request_id} by ActorID {actor_id}. " + f"Request not found or not in PENDING_APPROVAL status." + ) + return False + except Exception as e: + logger.error(f"Error cancelling ChangeRequestID {request_id} by ActorID {actor_id}: {e}") + return False def update_request_by_admin( self, conn: Connection, *, request_id: int, - status: str, + status: str, # ProductChangeRequestStatusApiEnum.value admin_notes: Optional[str], admin_reviewer_id: int, actor_id: Optional[int] = None, - ) -> Dict[str, Any]: + ) -> Optional[Dict[str, Any]]: """ - 管理员审核商品变更请求 - :param conn: 数据库连接 - :param request_id: 商品变更请求ID - :param status: 商品变更请求状态 - :param admin_notes: 管理员备注 - :param admin_reviewer_id: 管理员ID - :param actor_id: 操作者ID(通常为管理员ID) - :return: 商品变更请求数据字典 + 管理员审核商品变更请求 - 更新请求状态 + 此方法只会更新处于 'PENDING_APPROVAL' 状态的请求。 + :param conn: + :param request_id: + :param status: + :param admin_notes: + :param admin_reviewer_id: + :param actor_id: + :return: """ - raise NotImplementedError + logger.info( + f"AdminID {admin_reviewer_id} (ActorID {actor_id}) updating ChangeRequestID {request_id} to Status {status}.") + self._set_actor_session_variable(conn, actor_id if actor_id is not None else admin_reviewer_id) + + set_clauses: List[str] = [ + "Status = :Status", + "AdminReviewerID = :AdminReviewerID", + "ReviewTimestamp = UTC_TIMESTAMP()" + # LastUpdatedDate is handled by DB's ON UPDATE + ] + params: Dict[str, Any] = { + "ChangeRequestID_param": request_id, + "Status": status, + "AdminReviewerID": admin_reviewer_id, + "PendingStatus": "PENDING_APPROVAL" # 只允许更新 PENDING_APPROVAL 状态的请求 + } + + if admin_notes is not None: + set_clauses.append("AdminNotes = :AdminNotes") + params["AdminNotes"] = admin_notes + + update_stmt_str = (f"UPDATE {self.table_name}" + f" SET {', '.join(set_clauses)}" + f" WHERE ChangeRequestID = :ChangeRequestID_param AND Status = :PendingStatus") + + try: + result = conn.execute(text(update_stmt_str), params) + if result.rowcount == 0: + logger.warning(f"ChangeRequestID {request_id} not found for admin update, or no effective change.") + return self.get_request_by_id(conn, request_id=request_id) # Return current if exists + + logger.info(f"ChangeRequestID {request_id} updated by AdminID {admin_reviewer_id} to Status {status}.") + return self.get_request_by_id(conn, request_id=request_id) + except Exception as e: + logger.error(f"Error updating ChangeRequestID {request_id} by admin: {e}") + return None + + +# 单例实例 +product_change_request_crud_instance2 = ProductChangeRequestCRUD2.get_instance() diff --git a/src/backend/app/services/product_change_request_service_v2.py b/src/backend/app/services/product_change_request_service_v2.py new file mode 100644 index 0000000..e69de29 From 446f16c396177822ce2c211000cb383706d8a7b3 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 06:04:58 +0800 Subject: [PATCH 04/26] refactor(product_change): replace string literals with enum references for request types and statuses --- .../app/crud/product_change_request_crud_v2.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py index c1dbb43..96f873d 100644 --- a/src/backend/app/crud/product_change_request_crud_v2.py +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -7,7 +7,8 @@ # 假设这些枚举在您的 schema 文件中定义并可导入 -# from backend.app.schemas.product_change_request_schema_v2 import ProductChangeRequestTypeApiEnum, ProductChangeRequestStatusApiEnum +from backend.app.schemas.product_change_request_schema_v2 import \ + ProductChangeRequestTypeApiEnum as TypeEnum, ProductChangeRequestStatusApiEnum as StatusEnum class ProductChangeRequestCRUD2: """ @@ -285,7 +286,7 @@ def create_request_create_product( conn=conn, merchant_user_id=merchant_user_id, store_id=store_id, - request_type="PRODUCT_CREATE", # 直接使用字符串值 + request_type=TypeEnum.PRODUCT_CREATE, proposed_data_json=proposed_data_json, submitter_notes=submitter_notes, product_id=None, # ProductID 为空对于创建请求 @@ -320,7 +321,7 @@ def create_request_update_product( conn=conn, merchant_user_id=merchant_user_id, store_id=store_id, - request_type="PRODUCT_UPDATE", + request_type=TypeEnum.PRODUCT_UPDATE, proposed_data_json=proposed_data_json, submitter_notes=submitter_notes, product_id=product_id, @@ -353,14 +354,14 @@ def create_request_delete_product( conn=conn, merchant_user_id=merchant_user_id, store_id=store_id, - request_type="PRODUCT_DELETE", + request_type=TypeEnum.PRODUCT_DELETE, proposed_data_json=None, # 删除请求通常不需要建议数据体 submitter_notes=submitter_notes, product_id=product_id, actor_id=actor_id if actor_id is not None else merchant_user_id ) - def delete_request( # This method now means "cancel by merchant" + def cancel_request( # This method now means "cancel by merchant" self, conn: Connection, *, @@ -393,8 +394,8 @@ def delete_request( # This method now means "cancel by merchant" # from backend.app.schemas.product_change_request_schema_v2 import ProductChangeRequestStatusApiEnum # cancelled_status = ProductChangeRequestStatusApiEnum.CANCELLED_BY_USER.value # pending_status = ProductChangeRequestStatusApiEnum.PENDING_APPROVAL.value - cancelled_status = "CANCELLED_BY_USER" - pending_status = "PENDING_APPROVAL" + cancelled_status = StatusEnum.CANCELLED_BY_USER + pending_status = StatusEnum.PENDING_APPROVAL result = conn.execute(update_stmt, { "cancelled_status": cancelled_status, @@ -454,7 +455,7 @@ def update_request_by_admin( "ChangeRequestID_param": request_id, "Status": status, "AdminReviewerID": admin_reviewer_id, - "PendingStatus": "PENDING_APPROVAL" # 只允许更新 PENDING_APPROVAL 状态的请求 + "PendingStatus": StatusEnum.PENDING_APPROVAL } if admin_notes is not None: From 2bab2b6aa01b98ca853a31e96f7b13f56f1fb96d Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 06:06:02 +0800 Subject: [PATCH 05/26] refactor(product_change): remove unused PendingStatus from update statement --- src/backend/app/crud/product_change_request_crud_v2.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py index 96f873d..5181c62 100644 --- a/src/backend/app/crud/product_change_request_crud_v2.py +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -455,7 +455,6 @@ def update_request_by_admin( "ChangeRequestID_param": request_id, "Status": status, "AdminReviewerID": admin_reviewer_id, - "PendingStatus": StatusEnum.PENDING_APPROVAL } if admin_notes is not None: @@ -464,7 +463,7 @@ def update_request_by_admin( update_stmt_str = (f"UPDATE {self.table_name}" f" SET {', '.join(set_clauses)}" - f" WHERE ChangeRequestID = :ChangeRequestID_param AND Status = :PendingStatus") + f" WHERE ChangeRequestID = :ChangeRequestID_param") try: result = conn.execute(text(update_stmt_str), params) From 0232646c6572d4f60f5d1667c910f9a0260c6fdd Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 06:25:54 +0800 Subject: [PATCH 06/26] test(product_change): add unit tests for ProductChangeRequestCRUD2 methods --- .../test_product_change_request_crud_v2.py | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 src/backend/test/unit/crud/test_product_change_request_crud_v2.py diff --git a/src/backend/test/unit/crud/test_product_change_request_crud_v2.py b/src/backend/test/unit/crud/test_product_change_request_crud_v2.py new file mode 100644 index 0000000..9ea9aa5 --- /dev/null +++ b/src/backend/test/unit/crud/test_product_change_request_crud_v2.py @@ -0,0 +1,326 @@ +# src/backend/test/unit/crud/test_product_change_request_crud_v2.py +import unittest +from unittest.mock import MagicMock, patch, ANY, call +import datetime +import json # For asserting JSON string conversion +from typing import Optional, Dict, Any, List + +# 假设 ProductChangeRequestCRUD2 和相关枚举位于以下路径 +from backend.app.crud.product_change_request_crud_v2 import ProductChangeRequestCRUD2 +from backend.app.schemas.product_change_request_schema_v2 import ( + ProductChangeRequestTypeApiEnum as TypeEnum, + ProductChangeRequestStatusApiEnum as StatusEnum +) + +# 用于 SQLAlchemy text 和 exc (如果需要模拟异常) +from sqlalchemy import text, exc +from sqlalchemy.engine.base import Connection # 用于类型提示 + + +# 辅助函数来规范化SQL字符串以便比较 +def normalize_sql(sql_string: str) -> str: + """将SQL字符串中的多个空格和换行符替换为单个空格,并去除首尾空格。""" + return ' '.join(sql_string.strip().split()) + + +class TestProductChangeRequestCRUD2(unittest.TestCase): + + def setUp(self): + self.crud = ProductChangeRequestCRUD2.get_instance() + self.mock_conn = MagicMock(spec=Connection) + + self.mock_cursor_result = MagicMock() + self.mock_conn.execute.return_value = self.mock_cursor_result + + self.set_actor_patcher = patch.object(ProductChangeRequestCRUD2, '_set_actor_session_variable') + self.mock_set_actor_session_variable = self.set_actor_patcher.start() + self.addCleanup(self.set_actor_patcher.stop) + + # 准备一些通用的测试数据 + self.sample_request_data = { + "ChangeRequestID": 1, + "ProductID": 101, + "MerchantUserID": 201, + "StoreID": 301, + "RequestType": TypeEnum.PRODUCT_UPDATE.value, + "ProposedData_JSON": {"ProductName": "New Name"}, # 已是字典 + "Status": StatusEnum.PENDING_APPROVAL.value, + "SubmitterNotes": "Please review", + "AdminReviewerID": None, + "ReviewTimestamp": None, + "AdminNotes": None, + "CreationTime": datetime.datetime(2025, 1, 1, 10, 0, 0), + "LastUpdatedDate": datetime.datetime(2025, 1, 1, 10, 5, 0) + } + + # --- 测试 _deserialize_proposed_data 和 _serialize_proposed_data --- + def test_deserialize_proposed_data(self): + json_str = '{"key": "value"}' + expected_dict = {"key": "value"} + self.assertEqual(self.crud._deserialize_proposed_data(json_str), expected_dict) + self.assertEqual(self.crud._deserialize_proposed_data(expected_dict), expected_dict) # Should return dict as is + self.assertIsNone(self.crud._deserialize_proposed_data(None)) + with patch('backend.app.crud.product_change_request_crud_v2.logger') as mock_logger: + self.assertIsNone(self.crud._deserialize_proposed_data("invalid json")) + mock_logger.warning.assert_called_once() + + def test_serialize_proposed_data(self): + data_dict = {"key": "value"} + expected_json_str = json.dumps(data_dict) + self.assertEqual(self.crud._serialize_proposed_data(data_dict), expected_json_str) + self.assertEqual(self.crud._serialize_proposed_data(expected_json_str), + expected_json_str) # Should return str as is + self.assertIsNone(self.crud._serialize_proposed_data(None)) + + # --- 测试 get_request_by_id --- + def test_get_request_by_id_found(self): + request_id = 1 + mock_row = MagicMock() + # Simulate raw JSON string from DB for ProposedData_JSON + db_return_data = {**self.sample_request_data, "ProposedData_JSON": '{"ProductName": "New Name"}'} + mock_row._mapping = db_return_data + self.mock_cursor_result.fetchone.return_value = mock_row + + request = self.crud.get_request_by_id(self.mock_conn, request_id=request_id) + + self.mock_conn.execute.assert_called_once_with(ANY, {"ChangeRequestID": request_id}) + self.assertIsNotNone(request) + self.assertEqual(request["ChangeRequestID"], request_id) # type: ignore + self.assertEqual(request["ProposedData_JSON"], {"ProductName": "New Name"}) # type: ignore + + def test_get_request_by_id_not_found(self): + self.mock_cursor_result.fetchone.return_value = None + request = self.crud.get_request_by_id(self.mock_conn, request_id=999) + self.assertIsNone(request) + + # --- 测试 get_request_by_id_for_owner --- + def test_get_request_by_id_for_owner_found(self): + request_id = 1 + merchant_user_id = 201 + mock_row = MagicMock() + db_return_data = {**self.sample_request_data, "MerchantUserID": merchant_user_id, + "ProposedData_JSON": '{"key": "val"}'} + mock_row._mapping = db_return_data + self.mock_cursor_result.fetchone.return_value = mock_row + + request = self.crud.get_request_by_id_for_owner( + self.mock_conn, request_id=request_id, merchant_user_id=merchant_user_id + ) + self.mock_conn.execute.assert_called_once_with(ANY, {"ChangeRequestID": request_id, + "MerchantUserID": merchant_user_id}) + self.assertIsNotNone(request) + self.assertEqual(request["MerchantUserID"], merchant_user_id) # type: ignore + self.assertEqual(request["ProposedData_JSON"], {"key": "val"}) # type: ignore + + # --- 测试 get_request_list --- + def test_get_request_list_with_status_list_filter(self): + status_filter = [StatusEnum.PENDING_APPROVAL.value, StatusEnum.APPROVED.value] + mock_row1_db = {**self.sample_request_data, "ChangeRequestID": 1, "Status": status_filter[0], + "ProposedData_JSON": '{"p":1}'} + mock_row2_db = {**self.sample_request_data, "ChangeRequestID": 2, "Status": status_filter[1], + "ProposedData_JSON": '{"p":2}'} + row1 = MagicMock(); + row1._mapping = mock_row1_db + row2 = MagicMock(); + row2._mapping = mock_row2_db + self.mock_cursor_result.fetchall.return_value = [row1, row2] + + requests = self.crud.get_request_list(self.mock_conn, status=status_filter) + + self.mock_conn.execute.assert_called_once() + call_args = self.mock_conn.execute.call_args.args + normalized_sql = normalize_sql(str(call_args[0].text)) + self.assertIn("Status IN (:status_0, :status_1)", normalized_sql) + self.assertEqual(call_args[1]["status_0"], status_filter[0]) + self.assertEqual(call_args[1]["status_1"], status_filter[1]) + + self.assertEqual(len(requests), 2) + self.assertEqual(requests[0]["ProposedData_JSON"], {"p": 1}) + + def test_get_request_list_all_filters(self): + filters = { + "status": StatusEnum.APPROVED.value, + "request_type": TypeEnum.PRODUCT_UPDATE.value, + "store_id": 301, + "product_id": 101, + "merchant_user_id": 201 + } + self.mock_cursor_result.fetchall.return_value = [] # No need to check data, just query build + + self.crud.get_request_list(self.mock_conn, **filters) # type: ignore + + self.mock_conn.execute.assert_called_once() + call_args = self.mock_conn.execute.call_args.args + normalized_sql = normalize_sql(str(call_args[0].text)) + params = call_args[1] + + self.assertIn("Status = :Status_filter", normalized_sql) + self.assertEqual(params["Status_filter"], filters["status"]) + self.assertIn("RequestType = :RequestType", normalized_sql) + self.assertEqual(params["RequestType"], filters["request_type"]) + self.assertIn("StoreID = :StoreID", normalized_sql) + self.assertEqual(params["StoreID"], filters["store_id"]) + self.assertIn("ProductID = :ProductID", normalized_sql) + self.assertEqual(params["ProductID"], filters["product_id"]) + self.assertIn("MerchantUserID = :MerchantUserID", normalized_sql) + self.assertEqual(params["MerchantUserID"], filters["merchant_user_id"]) + + # --- 测试 _create_generic_request (通过其公共调用者) --- + def test_create_request_create_product_success(self): + merchant_user_id = 201 + store_id = 301 + actor_id = merchant_user_id + proposed_data = {"ProductName": "New Laptop", "Price": 1200.00} + submitter_notes = "New product for approval" + expected_request_id = 1001 + + self.mock_cursor_result.lastrowid = expected_request_id + + # Mock the get_request_by_id call made by _create_generic_request + mock_final_request_data = { + "ChangeRequestID": expected_request_id, "MerchantUserID": merchant_user_id, "StoreID": store_id, + "RequestType": TypeEnum.PRODUCT_CREATE.value, "ProposedData_JSON": proposed_data, + "SubmitterNotes": submitter_notes, "ProductID": None, "Status": StatusEnum.PENDING_APPROVAL.value + # ... other fields with defaults or None ... + } + with patch.object(self.crud, 'get_request_by_id', return_value=mock_final_request_data) as mock_get_by_id: + created_request = self.crud.create_request_create_product( + conn=self.mock_conn, merchant_user_id=merchant_user_id, store_id=store_id, + submitter_notes=submitter_notes, proposed_data_json=proposed_data, actor_id=actor_id + ) + self.mock_set_actor_session_variable.assert_called_with(self.mock_conn, + actor_id) # Called by _create_generic_request + self.mock_conn.execute.assert_called_once() # INSERT call + + call_args = self.mock_conn.execute.call_args.args + params = call_args[1] + self.assertEqual(params["RequestType"], TypeEnum.PRODUCT_CREATE.value) + self.assertEqual(params["ProposedData_JSON"], json.dumps(proposed_data)) # Serialized + self.assertIsNone(params["ProductID"]) + + mock_get_by_id.assert_called_once_with(self.mock_conn, request_id=expected_request_id) + self.assertEqual(created_request, mock_final_request_data) + + def test_create_request_delete_product_success(self): + merchant_user_id = 202 + store_id = 302 + product_id_to_delete = 105 + actor_id = merchant_user_id + submitter_notes = "Requesting deletion" + expected_request_id = 1002 + + self.mock_cursor_result.lastrowid = expected_request_id + mock_final_request_data = { + "ChangeRequestID": expected_request_id, "MerchantUserID": merchant_user_id, "StoreID": store_id, + "RequestType": TypeEnum.PRODUCT_DELETE.value, "ProposedData_JSON": None, + "SubmitterNotes": submitter_notes, "ProductID": product_id_to_delete, + "Status": StatusEnum.PENDING_APPROVAL.value + } + with patch.object(self.crud, 'get_request_by_id', return_value=mock_final_request_data) as mock_get_by_id: + created_request = self.crud.create_request_delete_product( + conn=self.mock_conn, merchant_user_id=merchant_user_id, store_id=store_id, + product_id=product_id_to_delete, submitter_notes=submitter_notes, actor_id=actor_id + ) + self.mock_conn.execute.assert_called_once() + params = self.mock_conn.execute.call_args.args[1] + self.assertEqual(params["RequestType"], TypeEnum.PRODUCT_DELETE.value) + self.assertIsNone(params["ProposedData_JSON"]) # Should be None for delete + self.assertEqual(params["ProductID"], product_id_to_delete) + self.assertEqual(created_request, mock_final_request_data) + + # --- 测试 cancel_request (原 delete_request) --- + def test_cancel_request_success(self): + request_id = 1 + actor_id = 201 # Merchant's ID + + self.mock_cursor_result.rowcount = 1 # Simulate update affected 1 row + + result = self.crud.cancel_request(self.mock_conn, request_id=request_id, actor_id=actor_id) + + self.assertTrue(result) + self.mock_set_actor_session_variable.assert_called_once_with(self.mock_conn, actor_id) + self.mock_conn.execute.assert_called_once() + + call_args = self.mock_conn.execute.call_args.args + normalized_sql = normalize_sql(str(call_args[0].text)) + params = call_args[1] + + self.assertIn(f"UPDATE {self.crud.table_name} SET Status = :cancelled_status", normalized_sql) + self.assertIn("WHERE ChangeRequestID = :request_id AND Status = :pending_status", normalized_sql) + self.assertEqual(params["cancelled_status"], StatusEnum.CANCELLED_BY_USER.value) # Check enum value + self.assertEqual(params["pending_status"], StatusEnum.PENDING_APPROVAL.value) + self.assertEqual(params["request_id"], request_id) + + def test_cancel_request_not_pending_or_not_found(self): + self.mock_cursor_result.rowcount = 0 # Simulate no row updated + result = self.crud.cancel_request(self.mock_conn, request_id=2, actor_id=201) + self.assertFalse(result) + + # --- 测试 update_request_by_admin --- + @patch('backend.app.crud.product_change_request_crud_v2.datetime') # To control ReviewTimestamp + def test_update_request_by_admin_approve_success(self, mock_datetime_module): + fixed_utc_now = datetime.datetime(2025, 5, 20, 14, 0, 0, tzinfo=datetime.timezone.utc) + # SUT uses UTC_TIMESTAMP() in SQL, which is fine. + # If SUT used Python datetime for ReviewTimestamp, we'd mock it. + # The DDL for ReviewTimestamp is DATETIME NULL. + # The SUT sets ReviewTimestamp = UTC_TIMESTAMP() in SQL. + # So, no need to mock datetime here unless we wanted to assert its value. + + request_id = 1 + admin_reviewer_id = 999 + actor_id = admin_reviewer_id + new_status = StatusEnum.APPROVED.value + admin_notes = "Looks good." + + self.mock_cursor_result.rowcount = 1 + + # Mock get_request_by_id for the return value + mock_updated_request_data = { + **self.sample_request_data, + "ChangeRequestID": request_id, "Status": new_status, + "AdminReviewerID": admin_reviewer_id, "AdminNotes": admin_notes, + "ReviewTimestamp": fixed_utc_now # Simulate what DB might return after update + } + with patch.object(self.crud, 'get_request_by_id', return_value=mock_updated_request_data) as mock_get_by_id: + updated_request = self.crud.update_request_by_admin( + conn=self.mock_conn, request_id=request_id, status=new_status, + admin_notes=admin_notes, admin_reviewer_id=admin_reviewer_id, actor_id=actor_id + ) + + self.mock_set_actor_session_variable.assert_called_with(self.mock_conn, actor_id) + self.mock_conn.execute.assert_called_once() + + call_args = self.mock_conn.execute.call_args.args + normalized_sql = normalize_sql(str(call_args[0].text)) + params = call_args[1] + + self.assertIn(f"UPDATE {self.crud.table_name} SET", normalized_sql) + self.assertIn("Status = :Status", normalized_sql) + self.assertIn("AdminReviewerID = :AdminReviewerID", normalized_sql) + self.assertIn("ReviewTimestamp = UTC_TIMESTAMP()", normalized_sql) # Check for SQL function + self.assertIn("AdminNotes = :AdminNotes", normalized_sql) + self.assertIn("WHERE ChangeRequestID = :ChangeRequestID_param", normalized_sql) + # Note: WHERE clause no longer checks for PENDING_APPROVAL + + self.assertEqual(params["Status"], new_status) + self.assertEqual(params["AdminReviewerID"], admin_reviewer_id) + self.assertEqual(params["AdminNotes"], admin_notes) + self.assertEqual(params["ChangeRequestID_param"], request_id) + + mock_get_by_id.assert_called_once_with(self.mock_conn, request_id=request_id) + self.assertEqual(updated_request, mock_updated_request_data) + + def test_update_request_by_admin_no_change_or_not_found(self): + self.mock_cursor_result.rowcount = 0 + # Simulate get_request_by_id returning None if not found after failed update + with patch.object(self.crud, 'get_request_by_id', return_value=None) as mock_get_by_id: + result = self.crud.update_request_by_admin( + conn=self.mock_conn, request_id=999, status=StatusEnum.REJECTED.value, + admin_notes="Not found", admin_reviewer_id=999, actor_id=999 + ) + self.assertIsNone(result) + mock_get_by_id.assert_called_once_with(self.mock_conn, request_id=999) + + +if __name__ == '__main__': + unittest.main(verbosity=2) From 2ddb8d35f0fade62ed462718f13d115cbacadd94 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Fri, 23 May 2025 17:56:00 +0800 Subject: [PATCH 07/26] feat(product_change): declare new endpoints and services for product change requests --- .../v1/endpoints/product_change_request_v2.py | 204 +++++++++++ src/backend/app/api/v1/router.py | 7 +- .../product_change_request_service_v2.py | 317 ++++++++++++++++++ 3 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 src/backend/app/api/v1/endpoints/product_change_request_v2.py diff --git a/src/backend/app/api/v1/endpoints/product_change_request_v2.py b/src/backend/app/api/v1/endpoints/product_change_request_v2.py new file mode 100644 index 0000000..7363b96 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/product_change_request_v2.py @@ -0,0 +1,204 @@ +# src/backend/app/api/v1/endpoints/product_change_request.py +import datetime + +from fastapi import APIRouter, Depends, HTTPException, status, Path as FastApiPath, Query +from typing import List, Optional + +# 依赖项导入 +from backend.app.dependencies.auth_deps import get_current_active_user, get_db_connection +# 服务层依赖通常在实际实现中注入,这里仅为签名占位 +# from backend.app.services.product_change_request_service import ProductChangeRequestService +# from backend.app.dependencies.service_deps import get_product_change_request_service + +# Schema 导入 - 使用您指定的 v2 文件名和更新后的类名 +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.product_change_request_schema_v2 import ( + ProductChangeRequestCreate, + ProductChangeRequestResponse, + ProductChangeRequestListResponse, + ProductChangeRequestUpdateByAdmin, + ProductChangeRequestQueryParams, + ProductChangeRequestStatusApiEnum, ProductChangeRequestTypeApiEnum +) + +from sqlalchemy.engine.base import Connection +from backend.app.utils import logger # 假设的 logger + +router = APIRouter() + + +@router.post( + "/", + response_model=ProductChangeRequestResponse, + status_code=status.HTTP_201_CREATED, + tags=["Product Change Requests"], + summary="商家提交新的商品变更请求" +) +async def submit_product_change_request( + request_in: ProductChangeRequestCreate, + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection) + # service: ProductChangeRequestService = Depends(get_product_change_request_service) # 服务层依赖 +): + """ + 商家用户提交一个新的商品变更请求(创建、更新或删除商品)。 + `MerchantUserID` 将从 `current_user` 中获取。 + """ + logger.info( + f"User {current_user.UserID} submitting product change request: {request_in.model_dump(exclude_unset=True)}") + + return ProductChangeRequestResponse( + ProductID=1, + StoreID=100, + RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, + SubmitterNotes="Test note", + ChangeRequestID=300, + MerchantUserID=500, + ProposedData_JSON={"key": "value"}, + Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, + AdminReviewerID=None, + ReviewTimestamp=None, + AdminNotes=None, + CreationTime=datetime.datetime.now(), + LastUpdatedDate=datetime.datetime.now() + ) + + +@router.get( + "/list/", + response_model=ProductChangeRequestListResponse, + tags=["Product Change Requests"], + summary="查询商品变更请求列表 (可按状态等筛选)" +) +async def list_product_change_requests( + query_params: ProductChangeRequestQueryParams = Depends(), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection) + # service: ProductChangeRequestService = Depends(get_product_change_request_service) +): + """ + 获取商品变更请求列表。 + - 商家用户通常只能看到自己提交的请求。 + - 管理员用户可以查看所有请求,并使用更多筛选条件。 + - 查询参数通过 `ProductChangeRequestQueryParams` Pydantic 模型接收。 + - **注意**: 您之前提到查询不需要分页。 + """ + logger.info( + f"User {current_user.UserID} listing product change requests with filters: {query_params.model_dump(exclude_none=True)}") + + return ProductChangeRequestListResponse( + Requests=[ + ProductChangeRequestResponse( + ProductID=1, + StoreID=100, + RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, + SubmitterNotes="Test note", + ChangeRequestID=300, + MerchantUserID=500, + ProposedData_JSON={"key": "value"}, + Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, + AdminReviewerID=None, + ReviewTimestamp=None, + AdminNotes=None, + CreationTime=datetime.datetime.now(), + LastUpdatedDate=datetime.datetime.now() + )], + TotalCount=1 + ) + + +@router.get( + "/{change_request_id}", + response_model=ProductChangeRequestResponse, + tags=["Product Change Requests"], + summary="获取单个商品变更请求的详情" +) +async def get_product_change_request_details( + change_request_id: int = FastApiPath(..., description="要检索的变更请求的唯一ID", gt=0), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection) + # service: ProductChangeRequestService = Depends(get_product_change_request_service) +): + """ + 根据 ChangeRequestID 获取单个商品变更请求的详细信息。 + 服务层需要验证当前用户是否有权查看此请求。 + """ + logger.info(f"User {current_user.UserID} fetching details for ProductChangeRequestID: {change_request_id}") + + return ProductChangeRequestResponse( + ProductID=1, + StoreID=100, + RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, + SubmitterNotes="Test note", + ChangeRequestID=300, + MerchantUserID=500, + ProposedData_JSON={"key": "value"}, + Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, + AdminReviewerID=None, + ReviewTimestamp=None, + AdminNotes=None, + CreationTime=datetime.datetime.now(), + LastUpdatedDate=datetime.datetime.now() + ) + + +@router.post( + "/{change_request_id}/review", + response_model=ProductChangeRequestResponse, + tags=["Product Change Requests (Admin)"], + summary="管理员审核商品变更请求" +) +async def admin_review_product_change_request( + review_data: ProductChangeRequestUpdateByAdmin, + change_request_id: int = FastApiPath(..., description="要审核的变更请求的唯一ID", gt=0), + admin_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection) + # service: ProductChangeRequestService = Depends(get_product_change_request_service) +): + """ + 管理员审核商品变更请求,可以将其状态更新为 'APPROVED' 或 'REJECTED',并添加审核备注。 + """ + # 实际应用中,admin_user 应通过专门的 get_current_admin_user 依赖注入,该依赖会进行权限检查 + logger.info( + f"Admin {admin_user.UserID} reviewing ProductChangeRequestID: {change_request_id} with review: {review_data.model_dump(exclude_unset=True)}") + + return ProductChangeRequestResponse( + ProductID=1, + StoreID=100, + RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, + SubmitterNotes="Test note", + ChangeRequestID=300, + MerchantUserID=500, + ProposedData_JSON={"key": "value"}, + Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, + AdminReviewerID=None, + ReviewTimestamp=None, + AdminNotes=None, + CreationTime=datetime.datetime.now(), + LastUpdatedDate=datetime.datetime.now() + ) + + + +@router.delete( + "/{change_request_id}", + status_code=status.HTTP_204_NO_CONTENT, + tags=["Product Change Requests"], + summary="删除商品变更请求 (如果业务逻辑允许)" +) +async def delete_product_change_request( + change_request_id: int = FastApiPath(..., description="要删除的变更请求的唯一ID", gt=0), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection) + # service: ProductChangeRequestService = Depends(get_product_change_request_service) +): + """ + 删除一个商品变更请求。 + 根据您的要求 "删除请求:这个表不需要定义删除",此端点的具体行为需要明确。 + 如果只是商家取消自己的待处理请求,应使用上面的修改接口。 + 如果管理员可以硬删除,则服务层需要实现该逻辑。 + """ + logger.warning( + f"User {current_user.UserID} attempting to DELETE ProductChangeRequestID: {change_request_id}. Business logic for deletion needs clarification.") + + return {"message": "Deletion logic not implemented. Please clarify the business rules."} diff --git a/src/backend/app/api/v1/router.py b/src/backend/app/api/v1/router.py index 953307f..596c1fc 100644 --- a/src/backend/app/api/v1/router.py +++ b/src/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter from .endpoints import user, product, category, auth, cart, address, order, payment, store,\ - store_change_request, product_change_request + store_change_request, product_change_request, product_change_request_v2 api_router_v1 = APIRouter() @@ -15,4 +15,7 @@ api_router_v1.include_router(payment.router, prefix="/payment", tags=["Payments"]) api_router_v1.include_router(store.router, prefix="/store", tags=["Store"]) api_router_v1.include_router(store_change_request.router, prefix="/store-change", tags=["Store Change Requests"]) -api_router_v1.include_router(product_change_request.router, prefix="/product-change", tags=["Product Change Requests"]) +api_router_v1.include_router(product_change_request.router, prefix="/product-change-deprecated", tags=["Product Change Requests"]) + + +api_router_v1.include_router(product_change_request_v2.router, prefix="/product-change", tags=["Product Change Requests V2"]) diff --git a/src/backend/app/services/product_change_request_service_v2.py b/src/backend/app/services/product_change_request_service_v2.py index e69de29..8db84f4 100644 --- a/src/backend/app/services/product_change_request_service_v2.py +++ b/src/backend/app/services/product_change_request_service_v2.py @@ -0,0 +1,317 @@ +# src/backend/app/services/product_change_request_service_v2.py +from sqlalchemy.engine.base import Connection +from typing import Optional, List, Dict, Any, Tuple + +from loguru import logger + +# 导入相关的 CRUD 类 +from backend.app.crud.product_change_request_crud_v2 import ProductChangeRequestCRUD2 +from backend.app.crud.product_crud import ProductCRUD # 用于应用变更 +from backend.app.crud.store_crud import StoreCRUD # 可能用于验证店铺所有权 +from backend.app.crud.user_crud import UserCRUD # 用于验证用户角色 + +# 导入相关的 Pydantic Schemas +from backend.app.schemas.product_change_request_schema_v2 import ( + ProductChangeRequestCreate, # 商家提交请求时使用 + ProductChangeRequestResponse, + ProductChangeRequestListResponse, + ProductChangeRequestUpdateByAdmin, # 管理员审核请求 + ProductChangeRequestQueryParams, # API 查询参数 + ProductChangeRequestTypeApiEnum as RequestTypeEnum, # 枚举 + ProductChangeRequestStatusApiEnum as RequestStatusEnum, # 枚举 + ProposedProductData, # 用于解析 ProposedData_JSON +) +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema # 用于 actor + +# 导入自定义异常 +from backend.app.utils.exceptions import ( + StoreNotFoundException, + ProductNotFoundException, + PermissionDeniedException, + InvalidOperationException, # 例如,尝试修改一个已处理的请求 + BadRequestException, # 例如,ProposedData_JSON 内容不符合 RequestType +) + + +class ProductChangeRequestService2: + """ + 商品变更请求的服务类,处理业务逻辑。 + """ + + def __init__( + self, + pcr_crud: ProductChangeRequestCRUD2, + product_crud: ProductCRUD, + store_crud: StoreCRUD, # 可能需要验证提交者是否为店铺所有者 + user_crud: UserCRUD, # 可能需要验证用户角色(例如,提交者是否为商家) + ): + self._pcr_crud = pcr_crud + self._product_crud = product_crud + self._store_crud = store_crud + self._user_crud = user_crud + logger.info(f"{self.__class__.__name__} initialized.") + + async def submit_new_request( + self, + db: Connection, + *, + merchant_user: CurrentUserSchema, # 已认证的商家用户 + request_in: ProductChangeRequestCreate, # 包含 StoreID, RequestType, ProposedData_JSON 等 + ) -> ProductChangeRequestResponse: + """ + 商家提交一个新的商品变更请求 (创建、更新或删除商品)。 + - 验证商家是否有权操作指定的 StoreID。 + - 根据 RequestType 验证 ProposedData_JSON 的内容是否基本合理。 + - (对于更新/删除) 验证 ProductID 是否属于指定的 StoreID。 + - 创建 ProductChangeRequest 记录,初始状态为 PENDING_APPROVAL。 + - 对于不需要审核的请求 (例如,更改库存、价格等),直接调用服务的 _apply_approved_request 方法。 + - 返回转换后的 ProductChangeRequestResponse 对象。 + + :param db: 数据库连接。 + :param merchant_user: 提交请求的商家用户。 + :param request_in: 包含请求详情的 Pydantic 模型。 + :return: 创建成功后的 ProductChangeRequestResponse 对象。 + :raises PermissionDeniedException: 如果商家无权操作该店铺。TODO: 后续使用权限检查类来处理 + :raises StoreNotFoundException: 如果 StoreID 无效。 + :raises ProductNotFoundException: 如果 RequestType 为 UPDATE/DELETE 时 ProductID 无效或不属于该店铺。 + :raises BadRequestException: 如果 ProposedData_JSON 内容不符合 RequestType 的基本要求。 + :raises Exception: 如果请求创建失败。 + """ + logger.info( + f"Merchant UserID {merchant_user.UserID} submitting new product change request for StoreID {request_in.StoreID}" + ) + # 1. 验证店铺所有权 (merchant_user.UserID 是否是 request_in.StoreID 的 OwnerUserID) + # 2. 根据 request_in.RequestType: + # a. PRODUCT_CREATE: 验证 ProposedData_JSON 是否包含创建商品所需的字段 (如 ProductName, Price, CategoryID)。 + # 确保 request_in.ProductID 为 None。 + # b. PRODUCT_UPDATE: 验证 request_in.ProductID 是否提供且有效,并且属于 request_in.StoreID。 + # 验证 ProposedData_JSON 是否提供且至少包含一个可更新字段。 + # c. PRODUCT_DELETE: 验证 request_in.ProductID 是否提供且有效,并且属于 request_in.StoreID。 + # 确保 request_in.ProposedData_JSON 为 None。 + # 3. 调用 self._pcr_crud.create_request_create_product / _update_product / _delete_product + # (或者一个更通用的 create_request 方法,传入解析后的 proposed_data) + # 4. 返回转换后的 ProductChangeRequestResponse + raise NotImplementedError + + async def get_request_details( + self, + db: Connection, + *, + change_request_id: int, + actor_user: CurrentUserSchema, # 执行操作的用户 + ) -> ProductChangeRequestResponse: + """ + 获取单个商品变更请求的详细信息。 + - 商家只能查看自己提交的请求。 + - 管理员可以查看任何请求。 + + :param db: 数据库连接。 + :param change_request_id: 要检索的变更请求的唯一ID。 + :param actor_user: 执行此操作的用户。 + :return: ProductChangeRequestResponse 对象。 + :raises PermissionDeniedException: 如果用户无权查看此请求。 + :raises RequestNotFoundException 如果请求未找到。 + """ + logger.info( + f"ActorID {actor_user.UserID} attempting to get details for ChangeRequestID {change_request_id}" + ) + # 1. 调用 self._pcr_crud.get_request_by_id() + # 2. 如果找到请求: + # a. 如果 actor_user 不是管理员,则验证 actor_user.UserID 是否等于请求的 MerchantUserID。 + # b. 如果权限不足,抛出 PermissionDeniedException。TODO: 后续使用权限检查类来处理 + # 3. 如果未找到请求,抛出 RequestNotFoundException (或 StoreNotFoundException,根据您的异常命名) + # 4. 返回转换后的 ProductChangeRequestResponse + raise NotImplementedError + + async def list_requests_for_merchant( + self, + db: Connection, + *, + merchant_user: CurrentUserSchema, # 当前商家用户 + query_params: ProductChangeRequestQueryParams, # 包含可选的 Status, RequestType 等筛选 + ) -> ProductChangeRequestListResponse: + """ + 获取当前登录商家提交的所有商品变更请求列表。 + 允许商家按状态、类型等筛选自己的请求。查询不分页。 + + :param db: 数据库连接。 + :param merchant_user: 当前商家用户。 + :param query_params: 查询参数,包含可选的筛选条件。 + :return: ProductChangeRequestListResponse 对象。 + """ + logger.info( + f"Merchant UserID {merchant_user.UserID} listing their product change requests with filters: {query_params.model_dump(exclude_none=True)}" + ) + # 1. 调用 self._pcr_crud.get_request_list(),并强制 merchant_user_id = merchant_user.UserID + # 将 query_params 中的 Status, RequestType 等传递给 CRUD。 + # 2. 返回 ProductChangeRequestListResponse (TotalCount 将是列表长度)。 + raise NotImplementedError + + async def list_requests_for_admin( + self, + db: Connection, + *, + admin_user: CurrentUserSchema, # 当前管理员用户 + query_params: ProductChangeRequestQueryParams, # 包含可选的 Status, RequestType, StoreID, MerchantUserID, ProductID 等筛选 + ) -> ProductChangeRequestListResponse: + """ + 管理员获取所有(或按条件筛选的)商品变更请求列表。查询不分页。 + + :param db: 数据库连接。 + :param admin_user: 当前管理员用户 (用于记录 actor_id)。 + :param query_params: 查询参数,包含可选的筛选条件。 + :return: ProductChangeRequestListResponse 对象。 + :raises PermissionDeniedException: 如果 admin_user 不是管理员 (理论上端点依赖已处理)。 + """ + logger.info( + f"Admin UserID {admin_user.UserID} listing all product change requests with filters: {query_params.model_dump(exclude_none=True)}" + ) + # 1. 再次确认 admin_user 确实是管理员角色。 + # 2. 调用 self._pcr_crud.get_request_list(),传递 query_params 中的所有筛选条件。 + # 3. 返回 ProductChangeRequestListResponse。 + raise NotImplementedError + + async def merchant_cancel_request( + self, + db: Connection, + *, + change_request_id: int, + merchant_user: CurrentUserSchema, # 执行操作的商家 + ) -> ProductChangeRequestResponse: + """ + 商家取消一个PENDING_APPROVAL状态的商品变更请求。 + - 验证请求是否属于该商家。 + - 验证请求当前状态是否为 PENDING_APPROVAL。 + - 调用 CRUD 的 cancel_request 方法更新状态为 CANCELLED_BY_USER。 + + :param db: 数据库连接。 + :param change_request_id: 要修改的变更请求ID。 + :param merchant_user: 执行操作的商家用户。 + :return: 更新后的 ProductChangeRequestResponse 对象。 + :raises PermissionDeniedException: 如果请求不属于该商家。TODO: 后续使用权限检查类来处理 + :raises InvalidOperationException: 如果请求状态不是 PENDING_APPROVAL (除非是取消操作)。 + :raises RequestNotFoundException: 如果请求未找到。 + :raises BadRequestException: 如果更新数据不符合要求 (例如,取消时提供了 ProposedData)。 + """ + logger.info( + f"Merchant UserID {merchant_user.UserID} attempting to update ChangeRequestID {change_request_id}" + ) + # 1. 获取请求,验证所有权和状态。 + # 2. 如果 update_data.Status 是 CANCELLED_BY_USER: + # a. 调用 self._pcr_crud.cancel_request() + # 3. 否则 (更新 ProposedData_JSON 或 SubmitterNotes): + # a. 需要一个新的 CRUD 方法,例如 update_pending_request_by_merchant(request_id, proposed_data, notes, actor_id) + # b. 或者,如果 _create_generic_request 能够处理更新(通过检查 request_id 是否存在), + # 但通常 INSERT 和 UPDATE 是分开的。 + # c. 更可能是需要一个 update_request_content_by_merchant 方法。 + # 4. 返回更新后的请求。 + raise NotImplementedError + + async def admin_review_request( + self, + db: Connection, + *, + change_request_id: int, + admin_user: CurrentUserSchema, # 执行审核的管理员 + review_data: ProductChangeRequestUpdateByAdmin, # 包含新的 Status (APPROVED/REJECTED) 和 AdminNotes + ) -> ProductChangeRequestResponse: + """ + 管理员审核商品变更请求。 + - 验证请求当前状态是否为 PENDING_APPROVAL。 + - 调用 CRUD 的 update_request_by_admin 方法更新状态、AdminReviewerID、ReviewTimestamp、AdminNotes。 + - 如果审核通过 (APPROVED),调用 _apply_approved_request() 方法来应用请求。 + + :param db: 数据库连接。 + :param change_request_id: 要审核的变更请求ID。 + :param admin_user: 执行审核的管理员用户。 + :param review_data: 包含审核结果 (新状态和备注) 的 Pydantic 模型。 + :return: 审核并更新后的 ProductChangeRequestResponse 对象。 + :raises InvalidOperationException: 如果请求状态不是 PENDING_APPROVAL。 + :raises RequestNotFoundException: 如果请求未找到。 + :raises Exception: 如果更新失败。 + """ + logger.info( + f"Admin UserID {admin_user.UserID} reviewing ChangeRequestID {change_request_id} with Status {review_data.Status}" + ) + # 1. 获取请求,验证当前状态是 PENDING_APPROVAL。 + # 2. 调用 self._pcr_crud.update_request_by_admin(),传递 review_data 中的 Status 和 AdminNotes, + # 以及 admin_user.UserID 作为 admin_reviewer_id。 + # 3. 返回更新后的请求。 + raise NotImplementedError + + async def apply_approved_request( + self, + db: Connection, + *, + change_request_id: int, + applier_user: CurrentUserSchema, # 执行应用的用户 + ) -> ProductChangeRequestResponse: + """ + 应用一个状态为 'APPROVED' 的商品变更请求。请求类型是 PRODUCT_CREATE/UPDATE/DELETE。 + - 根据 RequestType 执行相应的商品操作 (创建、更新 Product 表中的记录)。 + - 如果商品操作成功,则将 ProductChangeRequest 的状态更新为 'APPLIED', + 并(如果是 PRODUCT_CREATE)回填 ProductChangeRequest.ProductID。 + - 返回转换后的 ProductChangeRequestResponse 对象。 + + :param db: 数据库连接。 + :param change_request_id: 已批准的变更请求ID。 + :param applier_user: 执行应用的用户 (商家或管理员)。商家只可以应用自己的请求。TODO: 后续使用权限检查类来处理 + :return: ProductChangeRequestResponse (状态为 APPLIED)。 + :raises InvalidOperationException: 如果请求状态不是 APPROVED。 + :raises StoreNotFoundException: (RequestNotFoundException) 如果请求未找到。 + :raises ProductNotFoundException: 如果是 UPDATE/DELETE,但目标商品不存在。 + :raises Exception: 如果商品操作或请求状态更新失败。 + """ + logger.info( + f"UserID {applier_user.UserID} attempting to apply approved ChangeRequestID {change_request_id}" + ) + # 1. 获取请求,验证状态是 APPROVED。 + # a. 如果 applier_user 不是管理员,则验证 applier_user.UserID 是否等于请求的 MerchantUserID。 + # 2. 解析 ProposedData_JSON (如果 RequestType 是 CREATE 或 UPDATE)。 + # 3. 根据 RequestType: + # a. PRODUCT_CREATE: 调用 self._product_crud.create_product()。获取新 ProductID。 + # b. PRODUCT_UPDATE: 调用 self._product_crud.update_product()。 + # c. PRODUCT_DELETE: 调用 self._product_crud.delete_product()。 + # 4. 如果商品操作成功: + # a. 调用 self._pcr_crud.update_request_by_admin() (或一个专门的 _mark_request_applied 方法), + # 将状态设为 APPLIED,并(如果是 CREATE)回填 ProductID。 + # 5. 返回最终的请求状态。 + raise NotImplementedError + + + async def _apply_approved_request( + self, + db: Connection, + *, + change_request_id: int, + applier_user_id: int, # 执行应用的管理员用户ID + ) -> ProductChangeRequestResponse: + """ + (通常由管理员或后台任务调用) 应用一个状态为 'APPROVED' 的商品变更请求。 + - 根据 RequestType 执行相应的商品操作 (创建、更新、删除 Product 表中的记录)。 + - 如果商品操作成功,则将 ProductChangeRequest 的状态更新为 'APPLIED', + 并(如果是 PRODUCT_CREATE)回填 ProductChangeRequest.ProductID。 + + :param db: 数据库连接。 + :param change_request_id: 已批准的变更请求ID。 + :param applier_user_id: 执行应用的管理员用户ID/系统用户ID。留空表示系统自动应用。 + :return: ProductChangeRequestResponse (状态为 APPLIED)。 + :raises InvalidOperationException: 如果请求状态不是 APPROVED。 + :raises StoreNotFoundException: (RequestNotFoundException) 如果请求未找到。 + :raises ProductNotFoundException: 如果是 UPDATE/DELETE,但目标商品不存在。 + :raises Exception: 如果商品操作或请求状态更新失败。 + """ + logger.info( + f"Admin UserID {applier_user_id} attempting to apply approved ChangeRequestID {change_request_id}" + ) + # 1. 获取请求,验证状态是 APPROVED。 + # 2. 解析 ProposedData_JSON (如果 RequestType 是 CREATE 或 UPDATE)。 + # 3. 根据 RequestType: + # a. PRODUCT_CREATE: 调用 self._product_crud.create_product()。获取新 ProductID。 + # b. PRODUCT_UPDATE: 调用 self._product_crud.update_product()。 + # c. PRODUCT_DELETE: 调用 self._product_crud.delete_product()。 + # 4. 如果商品操作成功: + # a. 调用 self._pcr_crud.update_request_by_admin() (或一个专门的 _mark_request_applied 方法), + # 将状态设为 APPLIED,并(如果是 CREATE)回填 ProductID。 + # 5. 返回最终的请求状态。 + raise NotImplementedError From f1a7a203a20c4a695246d9bbdd65f3a147a291c3 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 03:11:16 +0800 Subject: [PATCH 08/26] refactor(product_change): move ProductStatusApiEnum to product_schema and update references --- .../schemas/product_change_request_schema_v2.py | 8 +------- src/backend/app/schemas/product_schema.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/backend/app/schemas/product_change_request_schema_v2.py b/src/backend/app/schemas/product_change_request_schema_v2.py index acf4200..baf02a9 100644 --- a/src/backend/app/schemas/product_change_request_schema_v2.py +++ b/src/backend/app/schemas/product_change_request_schema_v2.py @@ -7,13 +7,7 @@ from typing_extensions import Self - -class ProductStatusApiEnum(str, Enum): - """商品状态枚举""" - ACTIVE = "ACTIVE" - INACTIVE_BY_MERCHANT = "INACTIVE_BY_MERCHANT" - SUSPENDED_BY_ADMIN = "SUSPENDED_BY_ADMIN" - DISCONTINUED = "DISCONTINUED" +from .product_schema import ProductStatusApiEnum class ProductChangeRequestTypeApiEnum(str, Enum): diff --git a/src/backend/app/schemas/product_schema.py b/src/backend/app/schemas/product_schema.py index a01413c..b3b3d60 100644 --- a/src/backend/app/schemas/product_schema.py +++ b/src/backend/app/schemas/product_schema.py @@ -1,3 +1,4 @@ +from enum import Enum from pydantic import BaseModel, Field from typing import Optional, List, Literal import datetime @@ -5,6 +6,15 @@ from fastapi import Query + +class ProductStatusApiEnum(str, Enum): + """商品状态枚举""" + ACTIVE = "ACTIVE" + INACTIVE_BY_MERCHANT = "INACTIVE_BY_MERCHANT" + SUSPENDED_BY_ADMIN = "SUSPENDED_BY_ADMIN" + DISCONTINUED = "DISCONTINUED" + + class ProductBase(BaseModel): """ 商品基本信息模型 @@ -87,7 +97,7 @@ class ProductUpdate(BaseModel): max_length=512, description="商品主图片URL地址。", ) - ProductStatus: Optional[str] = Field( + ProductStatus: Optional[ProductStatusApiEnum] = Field( None, description="商品状态。可以是 'ACTIVE'、'INACTIVE_BY_MERCHANT'、'SUSPENDED_BY_ADMIN' 或 'DISCONTINUED'。", ) @@ -105,7 +115,7 @@ class ProductResponse(ProductBase): ..., description="商品所属店铺ID。", ) - ProductStatus: str = Field( + ProductStatus: ProductStatusApiEnum = Field( ..., description="商品状态。", ) @@ -249,4 +259,4 @@ class ProductListParams(BaseModel): 0, ge=0, description="分页偏移量,默认0。", - ) \ No newline at end of file + ) From e159c035e47886659a8352c312650f531ec7f029 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 03:11:23 +0800 Subject: [PATCH 09/26] feat(exceptions): add custom exception classes for invalid operations and bad requests --- src/backend/app/utils/exceptions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/backend/app/utils/exceptions.py b/src/backend/app/utils/exceptions.py index 474611c..2257b3a 100644 --- a/src/backend/app/utils/exceptions.py +++ b/src/backend/app/utils/exceptions.py @@ -85,3 +85,21 @@ class StoreNotFoundException(Exception): def __init__(self, detail: str = "Store not found"): self.detail = detail super().__init__(self.detail) + +class InvalidOperationException(Exception): + """无效操作异常。""" + def __init__(self, detail: str = "Invalid operation"): + self.detail = detail + super().__init__(self.detail) + +class BadRequestException(Exception): + """错误请求异常。""" + def __init__(self, detail: str = "Bad request"): + self.detail = detail + super().__init__(self.detail) + +class RequestNotFoundException(Exception): + """请求未找到异常。""" + def __init__(self, detail: str = "Request not found"): + self.detail = detail + super().__init__(self.detail) From c820a950c495ff3a75e5e347b301819323b139e3 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 04:11:59 +0800 Subject: [PATCH 10/26] feat(product_change): add method to update change request status to 'APPLIED' --- .../crud/product_change_request_crud_v2.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py index 5181c62..c3c35bb 100644 --- a/src/backend/app/crud/product_change_request_crud_v2.py +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -477,6 +477,56 @@ def update_request_by_admin( logger.error(f"Error updating ChangeRequestID {request_id} by admin: {e}") return None + def update_request_applied( + self, + conn: Connection, + *, + request_id: int, + actor_id: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """ + 管理员/商家审核商品变更请求 - 更新请求状态为已应用 + 此方法只会更新处于 'APPROVED' 状态的请求。 + TODO: test me + :param conn: + :param request_id: + :param actor_id: + :return: + """ + logger.info( + f"ActorID {actor_id} updating ChangeRequestID {request_id} to Status 'APPLIED'.") + self._set_actor_session_variable(conn, actor_id) + + update_stmt = text(f""" + UPDATE {self.table_name} + SET Status = :applied_status + WHERE ChangeRequestID = :request_id AND Status = :approved_status + """) + # LastUpdatedDate 会由数据库的 ON UPDATE CURRENT_TIMESTAMP 自动处理 + + try: + applied_status = StatusEnum.APPLIED + approved_status = StatusEnum.APPROVED + + result = conn.execute(update_stmt, { + "applied_status": applied_status, + "request_id": request_id, + "approved_status": approved_status + }) + + if result.rowcount > 0: + logger.info(f"ChangeRequestID {request_id} status updated to {applied_status} by ActorID {actor_id}.") + return self.get_request_by_id(conn, request_id=request_id) + else: + logger.warning( + f"Failed to update ChangeRequestID {request_id} to {applied_status} by ActorID {actor_id}. " + f"Request not found or not in APPROVED status." + ) + return None + except Exception as e: + logger.error(f"Error updating ChangeRequestID {request_id} to APPLIED by ActorID {actor_id}: {e}") + return None + # 单例实例 product_change_request_crud_instance2 = ProductChangeRequestCRUD2.get_instance() From 0f566030190422ebf0352c12c05f9ef29785eee0 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 04:13:44 +0800 Subject: [PATCH 11/26] feat(product_change_request_service): implementation --- .../product_change_request_service_v2.py | 747 +++++++++++++----- 1 file changed, 538 insertions(+), 209 deletions(-) diff --git a/src/backend/app/services/product_change_request_service_v2.py b/src/backend/app/services/product_change_request_service_v2.py index 8db84f4..c8ba34a 100644 --- a/src/backend/app/services/product_change_request_service_v2.py +++ b/src/backend/app/services/product_change_request_service_v2.py @@ -1,35 +1,35 @@ # src/backend/app/services/product_change_request_service_v2.py from sqlalchemy.engine.base import Connection -from typing import Optional, List, Dict, Any, Tuple +from typing import Optional, List, Dict, Any +from decimal import Decimal from loguru import logger # 导入相关的 CRUD 类 from backend.app.crud.product_change_request_crud_v2 import ProductChangeRequestCRUD2 -from backend.app.crud.product_crud import ProductCRUD # 用于应用变更 -from backend.app.crud.store_crud import StoreCRUD # 可能用于验证店铺所有权 -from backend.app.crud.user_crud import UserCRUD # 用于验证用户角色 +from backend.app.crud.product_crud import ProductCRUD +from backend.app.crud.store_crud import StoreCRUD +from backend.app.crud.user_crud import UserCRUD # 导入相关的 Pydantic Schemas from backend.app.schemas.product_change_request_schema_v2 import ( - ProductChangeRequestCreate, # 商家提交请求时使用 + ProductChangeRequestCreate, ProductChangeRequestResponse, ProductChangeRequestListResponse, - ProductChangeRequestUpdateByAdmin, # 管理员审核请求 - ProductChangeRequestQueryParams, # API 查询参数 - ProductChangeRequestTypeApiEnum as RequestTypeEnum, # 枚举 - ProductChangeRequestStatusApiEnum as RequestStatusEnum, # 枚举 - ProposedProductData, # 用于解析 ProposedData_JSON + ProductChangeRequestUpdateByAdmin, + ProductChangeRequestQueryParams, + ProductChangeRequestTypeApiEnum as RequestTypeEnum, + ProductChangeRequestStatusApiEnum as RequestStatusEnum, + ProposedProductData, ) -from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema # 用于 actor +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema # 导入自定义异常 from backend.app.utils.exceptions import ( StoreNotFoundException, ProductNotFoundException, - PermissionDeniedException, - InvalidOperationException, # 例如,尝试修改一个已处理的请求 - BadRequestException, # 例如,ProposedData_JSON 内容不符合 RequestType + InvalidOperationException, + BadRequestException, ) @@ -42,8 +42,8 @@ def __init__( self, pcr_crud: ProductChangeRequestCRUD2, product_crud: ProductCRUD, - store_crud: StoreCRUD, # 可能需要验证提交者是否为店铺所有者 - user_crud: UserCRUD, # 可能需要验证用户角色(例如,提交者是否为商家) + store_crud: StoreCRUD, + user_crud: UserCRUD, ): self._pcr_crud = pcr_crud self._product_crud = product_crud @@ -55,263 +55,592 @@ async def submit_new_request( self, db: Connection, *, - merchant_user: CurrentUserSchema, # 已认证的商家用户 - request_in: ProductChangeRequestCreate, # 包含 StoreID, RequestType, ProposedData_JSON 等 + merchant_user: CurrentUserSchema, + request_in: ProductChangeRequestCreate, ) -> ProductChangeRequestResponse: """ - 商家提交一个新的商品变更请求 (创建、更新或删除商品)。 - - 验证商家是否有权操作指定的 StoreID。 - - 根据 RequestType 验证 ProposedData_JSON 的内容是否基本合理。 - - (对于更新/删除) 验证 ProductID 是否属于指定的 StoreID。 - - 创建 ProductChangeRequest 记录,初始状态为 PENDING_APPROVAL。 - - 对于不需要审核的请求 (例如,更改库存、价格等),直接调用服务的 _apply_approved_request 方法。 - - 返回转换后的 ProductChangeRequestResponse 对象。 - - :param db: 数据库连接。 - :param merchant_user: 提交请求的商家用户。 - :param request_in: 包含请求详情的 Pydantic 模型。 - :return: 创建成功后的 ProductChangeRequestResponse 对象。 - :raises PermissionDeniedException: 如果商家无权操作该店铺。TODO: 后续使用权限检查类来处理 - :raises StoreNotFoundException: 如果 StoreID 无效。 - :raises ProductNotFoundException: 如果 RequestType 为 UPDATE/DELETE 时 ProductID 无效或不属于该店铺。 - :raises BadRequestException: 如果 ProposedData_JSON 内容不符合 RequestType 的基本要求。 - :raises Exception: 如果请求创建失败。 + 提交新的商品变更请求。 + + :param db: 数据库连接对象,用于执行数据库操作。 + :param merchant_user: 当前商家用户的详细信息,包含用户ID等。 + :param request_in: 包含商品变更请求的输入数据,基于 Pydantic 模型。 + :return: 返回创建的商品变更请求的详细信息,基于 Pydantic 响应模型。 + :raises StoreNotFoundException: 如果找不到指定的店铺。 + :raises ProductNotFoundException: 如果找不到指定的商品。 + :raises PermissionDeniedException: 如果商家没有权限提交请求。 + :raises BadRequestException: 如果请求数据不符合要求。 """ logger.info( - f"Merchant UserID {merchant_user.UserID} submitting new product change request for StoreID {request_in.StoreID}" + f"Merchant UserID {merchant_user.UserID} submitting new product change request for StoreID {request_in.StoreID}, Type: {request_in.RequestType.value}" + ) + + # 1. 验证店铺所有权 + store = self._store_crud.get_store_by_id( + conn=db, store_id=request_in.StoreID, actor_id=merchant_user.UserID ) - # 1. 验证店铺所有权 (merchant_user.UserID 是否是 request_in.StoreID 的 OwnerUserID) - # 2. 根据 request_in.RequestType: - # a. PRODUCT_CREATE: 验证 ProposedData_JSON 是否包含创建商品所需的字段 (如 ProductName, Price, CategoryID)。 - # 确保 request_in.ProductID 为 None。 - # b. PRODUCT_UPDATE: 验证 request_in.ProductID 是否提供且有效,并且属于 request_in.StoreID。 - # 验证 ProposedData_JSON 是否提供且至少包含一个可更新字段。 - # c. PRODUCT_DELETE: 验证 request_in.ProductID 是否提供且有效,并且属于 request_in.StoreID。 - # 确保 request_in.ProposedData_JSON 为 None。 - # 3. 调用 self._pcr_crud.create_request_create_product / _update_product / _delete_product - # (或者一个更通用的 create_request 方法,传入解析后的 proposed_data) - # 4. 返回转换后的 ProductChangeRequestResponse - raise NotImplementedError + if not store: + raise StoreNotFoundException(f"Store with ID {request_in.StoreID} not found.") + if store["OwnerUserID"] != merchant_user.UserID: + # TODO: 替换为权限检查类的调用 + logger.warning( + f"Permission Denied: Merchant {merchant_user.UserID} does not own Store {request_in.StoreID}." + ) + # raise PermissionDeniedException( + # f"You do not have permission to submit requests for store {request_in.StoreID}." + # ) + + # 2. 根据 RequestType 验证 ProposedData_JSON 和 ProductID + proposed_data_dict: Optional[Dict[str, Any]] = None + if request_in.ProposedData_JSON: + # Pydantic schema (request_in) 已经将 ProposedData_JSON 解析为 ProposedProductData 对象 + # CRUD 层期望的是字典,所以我们 dump 它 + proposed_data_dict = request_in.ProposedData_JSON.model_dump(exclude_unset=True) + + if request_in.RequestType == RequestTypeEnum.PRODUCT_CREATE: + # 要求 ProductID 为 None,并提供 ProposedData_JSON + if request_in.ProductID is not None: + raise BadRequestException("ProductID must be null for PRODUCT_CREATE requests.") + if not proposed_data_dict: # ProposedData_JSON (as dict) is required for create + raise BadRequestException( + "ProposedData_JSON is required for PRODUCT_CREATE requests." + ) + + # 验证 ProposedProductData 中的必填字段 (基于 ProposedProductData schema 的注释) + required_fields = [ + "ProductName", + "Price", + "CategoryID", + ] # StockQuantity 是可选的,默认为0 + for field in required_fields: + if ( + proposed_data_dict.get(field) is None + ): # or not proposed_data_dict.get(field) if empty string is also invalid + raise BadRequestException( + f"Field '{field}' in ProposedData_JSON is required for PRODUCT_CREATE." + ) + + # 确保 Price 和 StockQuantity (如果提供) 是有效的数字 + if "Price" in proposed_data_dict and not isinstance( + proposed_data_dict["Price"], (int, float, Decimal) + ): + raise BadRequestException( + "Price in ProposedData_JSON must be a valid number for PRODUCT_CREATE." + ) + if ( + "StockQuantity" in proposed_data_dict + and proposed_data_dict["StockQuantity"] is not None + and not isinstance(proposed_data_dict["StockQuantity"], int) + ): + raise BadRequestException( + "StockQuantity in ProposedData_JSON must be a valid integer if provided for PRODUCT_CREATE." + ) + + elif request_in.RequestType == RequestTypeEnum.PRODUCT_UPDATE: + if request_in.ProductID is None: + raise BadRequestException("ProductID is required for PRODUCT_UPDATE requests.") + if not proposed_data_dict: # ProposedData_JSON (as dict) is required for update + raise BadRequestException( + "ProposedData_JSON is required for PRODUCT_UPDATE requests and must contain fields to update." + ) + if not any(proposed_data_dict.values()): # 确保至少有一个字段被更新 + raise BadRequestException( + "ProposedData_JSON must contain at least one field to update for PRODUCT_UPDATE requests." + ) + + # 验证 ProductID 属于 StoreID + product_to_update = self._product_crud.get_product_by_id( + conn=db, product_id=request_in.ProductID, actor_id=merchant_user.UserID + ) + if not product_to_update or product_to_update.get("StoreID") != request_in.StoreID: + raise ProductNotFoundException( + f"Product with ID {request_in.ProductID} not found in Store {request_in.StoreID}." + ) + + elif request_in.RequestType == RequestTypeEnum.PRODUCT_DELETE: + if request_in.ProductID is None: + raise BadRequestException("ProductID is required for PRODUCT_DELETE requests.") + if ( + proposed_data_dict is not None + ): # ProposedData_JSON (as dict) should be None for delete + raise BadRequestException( + "ProposedData_JSON must not be provided for PRODUCT_DELETE requests." + ) + + # 验证 ProductID 属于 StoreID + product_to_delete = self._product_crud.get_product_by_id( + conn=db, product_id=request_in.ProductID, actor_id=merchant_user.UserID + ) + if not product_to_delete or product_to_delete.get("StoreID") != request_in.StoreID: + raise ProductNotFoundException( + f"Product with ID {request_in.ProductID} not found in Store {request_in.StoreID} for deletion." + ) + + # 3. 调用 CRUD 创建请求记录 + created_request_dict: Optional[Dict[str, Any]] = None + if request_in.RequestType == RequestTypeEnum.PRODUCT_CREATE: + created_request_dict = self._pcr_crud.create_request_create_product( + conn=db, + merchant_user_id=merchant_user.UserID, + store_id=request_in.StoreID, + submitter_notes=request_in.SubmitterNotes, + proposed_data_json=proposed_data_dict, # type: ignore + actor_id=merchant_user.UserID, + ) + elif request_in.RequestType == RequestTypeEnum.PRODUCT_UPDATE: + created_request_dict = self._pcr_crud.create_request_update_product( + conn=db, + product_id=request_in.ProductID, # type: ignore + merchant_user_id=merchant_user.UserID, + store_id=request_in.StoreID, + submitter_notes=request_in.SubmitterNotes, + proposed_data_json=proposed_data_dict, # type: ignore + actor_id=merchant_user.UserID, + ) + elif request_in.RequestType == RequestTypeEnum.PRODUCT_DELETE: + created_request_dict = self._pcr_crud.create_request_delete_product( + conn=db, + product_id=request_in.ProductID, # type: ignore + merchant_user_id=merchant_user.UserID, + store_id=request_in.StoreID, + submitter_notes=request_in.SubmitterNotes, + actor_id=merchant_user.UserID, + ) + + if not created_request_dict: + logger.error( + f"Failed to create product change request in CRUD layer for Merchant {merchant_user.UserID}, Store {request_in.StoreID}" + ) + raise Exception("Failed to submit product change request.") + + logger.success( + f"ProductChangeRequest ID {created_request_dict['ChangeRequestID']} submitted by Merchant {merchant_user.UserID}." + ) + return ProductChangeRequestResponse(**created_request_dict) async def get_request_details( self, db: Connection, *, change_request_id: int, - actor_user: CurrentUserSchema, # 执行操作的用户 + actor_user: CurrentUserSchema, ) -> ProductChangeRequestResponse: """ 获取单个商品变更请求的详细信息。 - - 商家只能查看自己提交的请求。 - - 管理员可以查看任何请求。 - - :param db: 数据库连接。 - :param change_request_id: 要检索的变更请求的唯一ID。 - :param actor_user: 执行此操作的用户。 - :return: ProductChangeRequestResponse 对象。 - :raises PermissionDeniedException: 如果用户无权查看此请求。 - :raises RequestNotFoundException 如果请求未找到。 + :param db: + :param change_request_id: + :param actor_user: + :return: """ logger.info( f"ActorID {actor_user.UserID} attempting to get details for ChangeRequestID {change_request_id}" ) - # 1. 调用 self._pcr_crud.get_request_by_id() - # 2. 如果找到请求: - # a. 如果 actor_user 不是管理员,则验证 actor_user.UserID 是否等于请求的 MerchantUserID。 - # b. 如果权限不足,抛出 PermissionDeniedException。TODO: 后续使用权限检查类来处理 - # 3. 如果未找到请求,抛出 RequestNotFoundException (或 StoreNotFoundException,根据您的异常命名) - # 4. 返回转换后的 ProductChangeRequestResponse - raise NotImplementedError + + request_data = self._pcr_crud.get_request_by_id(conn=db, request_id=change_request_id) + if not request_data: + raise ProductNotFoundException( + f"ProductChangeRequest with ID {change_request_id} not found." + ) # Changed to ProductNotFoundException for consistency if it's a general "request not found" + + # 权限检查: 商家只能看自己的,管理员可以看所有 + # TODO: 替换为权限检查类的调用 + is_admin = hasattr(actor_user, "UserRole") and actor_user.UserRole == "admin" + if not is_admin and request_data["MerchantUserID"] != actor_user.UserID: + logger.warning( + f"Permission Denied: ActorID {actor_user.UserID} cannot view ChangeRequestID {change_request_id} owned by MerchantID {request_data['MerchantUserID']}." + ) + # raise PermissionDeniedException("You do not have permission to view this request.") + + return ProductChangeRequestResponse(**request_data) async def list_requests_for_merchant( self, db: Connection, *, - merchant_user: CurrentUserSchema, # 当前商家用户 - query_params: ProductChangeRequestQueryParams, # 包含可选的 Status, RequestType 等筛选 + merchant_user: CurrentUserSchema, + query_params: ProductChangeRequestQueryParams, ) -> ProductChangeRequestListResponse: """ - 获取当前登录商家提交的所有商品变更请求列表。 - 允许商家按状态、类型等筛选自己的请求。查询不分页。 - - :param db: 数据库连接。 - :param merchant_user: 当前商家用户。 - :param query_params: 查询参数,包含可选的筛选条件。 - :return: ProductChangeRequestListResponse 对象。 + 商家用户列出自己的商品变更请求列表,支持按状态等筛选。 + :param db: + :param merchant_user: + :param query_params: + :return: """ logger.info( f"Merchant UserID {merchant_user.UserID} listing their product change requests with filters: {query_params.model_dump(exclude_none=True)}" ) - # 1. 调用 self._pcr_crud.get_request_list(),并强制 merchant_user_id = merchant_user.UserID - # 将 query_params 中的 Status, RequestType 等传递给 CRUD。 - # 2. 返回 ProductChangeRequestListResponse (TotalCount 将是列表长度)。 - raise NotImplementedError + + # 将 Pydantic QueryParams 转换为 CRUD 方法期望的参数 + status_list_values: Optional[List[str]] = None + if query_params.Status: + status_list_values = [s.value for s in query_params.Status] + + request_type_value: Optional[str] = None + if query_params.RequestType: + request_type_value = query_params.RequestType.value + + requests_data = self._pcr_crud.get_request_list( + conn=db, + merchant_user_id=merchant_user.UserID, # 强制商家ID + status=status_list_values, + request_type=request_type_value, + store_id=query_params.StoreID, + product_id=query_params.ProductID, + # CRUD get_request_list 不处理分页,所以 query_params 中的分页参数在此处不使用 + ) + + response_items = [ProductChangeRequestResponse(**data) for data in requests_data] + return ProductChangeRequestListResponse( + Requests=response_items, TotalCount=len(response_items) + ) async def list_requests_for_admin( self, db: Connection, *, - admin_user: CurrentUserSchema, # 当前管理员用户 - query_params: ProductChangeRequestQueryParams, # 包含可选的 Status, RequestType, StoreID, MerchantUserID, ProductID 等筛选 + admin_user: CurrentUserSchema, + query_params: ProductChangeRequestQueryParams, ) -> ProductChangeRequestListResponse: - """ - 管理员获取所有(或按条件筛选的)商品变更请求列表。查询不分页。 - - :param db: 数据库连接。 - :param admin_user: 当前管理员用户 (用于记录 actor_id)。 - :param query_params: 查询参数,包含可选的筛选条件。 - :return: ProductChangeRequestListResponse 对象。 - :raises PermissionDeniedException: 如果 admin_user 不是管理员 (理论上端点依赖已处理)。 - """ logger.info( f"Admin UserID {admin_user.UserID} listing all product change requests with filters: {query_params.model_dump(exclude_none=True)}" ) - # 1. 再次确认 admin_user 确实是管理员角色。 - # 2. 调用 self._pcr_crud.get_request_list(),传递 query_params 中的所有筛选条件。 - # 3. 返回 ProductChangeRequestListResponse。 - raise NotImplementedError + # TODO: 替换为权限检查类的调用 (确保 admin_user 确实是管理员) + # if not (hasattr(admin_user, 'UserRole') and admin_user.UserRole == "admin"): + # raise PermissionDeniedException("Only administrators can list all requests.") + + status_list_values: Optional[List[str]] = None + if query_params.Status: + status_list_values = [s.value for s in query_params.Status] + + request_type_value: Optional[str] = None + if query_params.RequestType: + request_type_value = query_params.RequestType.value + + requests_data = self._pcr_crud.get_request_list( + conn=db, + status=status_list_values, + request_type=request_type_value, + store_id=query_params.StoreID, + product_id=query_params.ProductID, + merchant_user_id=query_params.MerchantUserID, # 管理员可以按商家筛选 + ) + response_items = [ProductChangeRequestResponse(**data) for data in requests_data] + return ProductChangeRequestListResponse( + Requests=response_items, TotalCount=len(response_items) + ) async def merchant_cancel_request( self, db: Connection, *, change_request_id: int, - merchant_user: CurrentUserSchema, # 执行操作的商家 + merchant_user: CurrentUserSchema, ) -> ProductChangeRequestResponse: - """ - 商家取消一个PENDING_APPROVAL状态的商品变更请求。 - - 验证请求是否属于该商家。 - - 验证请求当前状态是否为 PENDING_APPROVAL。 - - 调用 CRUD 的 cancel_request 方法更新状态为 CANCELLED_BY_USER。 - - :param db: 数据库连接。 - :param change_request_id: 要修改的变更请求ID。 - :param merchant_user: 执行操作的商家用户。 - :return: 更新后的 ProductChangeRequestResponse 对象。 - :raises PermissionDeniedException: 如果请求不属于该商家。TODO: 后续使用权限检查类来处理 - :raises InvalidOperationException: 如果请求状态不是 PENDING_APPROVAL (除非是取消操作)。 - :raises RequestNotFoundException: 如果请求未找到。 - :raises BadRequestException: 如果更新数据不符合要求 (例如,取消时提供了 ProposedData)。 - """ logger.info( - f"Merchant UserID {merchant_user.UserID} attempting to update ChangeRequestID {change_request_id}" + f"Merchant UserID {merchant_user.UserID} attempting to cancel ChangeRequestID {change_request_id}" + ) + + # 1. 获取请求,验证所有权和状态 + request_to_cancel = self._pcr_crud.get_request_by_id(conn=db, request_id=change_request_id) + if not request_to_cancel: + raise ProductNotFoundException( + f"ProductChangeRequest with ID {change_request_id} not found." + ) # Or RequestNotFoundException + + if request_to_cancel["MerchantUserID"] != merchant_user.UserID: + # TODO: 替换为权限检查类的调用 + logger.warning( + f"Permission Denied: Merchant {merchant_user.UserID} cannot cancel request {change_request_id} owned by {request_to_cancel['MerchantUserID']}." + ) + # raise PermissionDeniedException("You do not have permission to cancel this request.") + + if request_to_cancel["Status"] != RequestStatusEnum.PENDING_APPROVAL.value: + logger.warning( + f"Invalid operation: Request {change_request_id} is not in PENDING_APPROVAL status (current: {request_to_cancel['Status']})." + ) + raise InvalidOperationException( + f"Request is not in PENDING_APPROVAL status, cannot be cancelled by merchant." + ) + + # 2. 调用 CRUD 的 cancel_request 方法 (该方法内部将状态设为 CANCELLED_BY_USER) + success = self._pcr_crud.cancel_request( # CRUD's cancel_request was named delete_request + conn=db, request_id=change_request_id, actor_id=merchant_user.UserID + ) + + if not success: + logger.error(f"Failed to cancel request {change_request_id} in CRUD layer.") + # This might happen if the status changed concurrently, or DB error + raise Exception("Failed to cancel the request.") + + updated_request_dict = self._pcr_crud.get_request_by_id( + conn=db, request_id=change_request_id + ) + if not updated_request_dict: # Should not happen if cancel was successful + raise ProductNotFoundException( + f"Request {change_request_id} not found after cancellation attempt." + ) + + logger.success( + f"ChangeRequestID {change_request_id} cancelled by Merchant {merchant_user.UserID}." ) - # 1. 获取请求,验证所有权和状态。 - # 2. 如果 update_data.Status 是 CANCELLED_BY_USER: - # a. 调用 self._pcr_crud.cancel_request() - # 3. 否则 (更新 ProposedData_JSON 或 SubmitterNotes): - # a. 需要一个新的 CRUD 方法,例如 update_pending_request_by_merchant(request_id, proposed_data, notes, actor_id) - # b. 或者,如果 _create_generic_request 能够处理更新(通过检查 request_id 是否存在), - # 但通常 INSERT 和 UPDATE 是分开的。 - # c. 更可能是需要一个 update_request_content_by_merchant 方法。 - # 4. 返回更新后的请求。 - raise NotImplementedError + return ProductChangeRequestResponse(**updated_request_dict) async def admin_review_request( self, db: Connection, *, change_request_id: int, - admin_user: CurrentUserSchema, # 执行审核的管理员 - review_data: ProductChangeRequestUpdateByAdmin, # 包含新的 Status (APPROVED/REJECTED) 和 AdminNotes + admin_user: CurrentUserSchema, + review_data: ProductChangeRequestUpdateByAdmin, ) -> ProductChangeRequestResponse: - """ - 管理员审核商品变更请求。 - - 验证请求当前状态是否为 PENDING_APPROVAL。 - - 调用 CRUD 的 update_request_by_admin 方法更新状态、AdminReviewerID、ReviewTimestamp、AdminNotes。 - - 如果审核通过 (APPROVED),调用 _apply_approved_request() 方法来应用请求。 - - :param db: 数据库连接。 - :param change_request_id: 要审核的变更请求ID。 - :param admin_user: 执行审核的管理员用户。 - :param review_data: 包含审核结果 (新状态和备注) 的 Pydantic 模型。 - :return: 审核并更新后的 ProductChangeRequestResponse 对象。 - :raises InvalidOperationException: 如果请求状态不是 PENDING_APPROVAL。 - :raises RequestNotFoundException: 如果请求未找到。 - :raises Exception: 如果更新失败。 - """ logger.info( - f"Admin UserID {admin_user.UserID} reviewing ChangeRequestID {change_request_id} with Status {review_data.Status}" + f"Admin UserID {admin_user.UserID} reviewing ChangeRequestID {change_request_id} with Status {review_data.Status.value}" ) - # 1. 获取请求,验证当前状态是 PENDING_APPROVAL。 - # 2. 调用 self._pcr_crud.update_request_by_admin(),传递 review_data 中的 Status 和 AdminNotes, - # 以及 admin_user.UserID 作为 admin_reviewer_id。 - # 3. 返回更新后的请求。 - raise NotImplementedError + # TODO: 替换为权限检查类的调用 (确保 admin_user 是管理员) + # if not (hasattr(admin_user, 'UserRole') and admin_user.UserRole == "admin"): + # raise PermissionDeniedException("Only administrators can review requests.") + + # 1. 获取请求,验证当前状态是 PENDING_APPROVAL + request_to_review = self._pcr_crud.get_request_by_id(conn=db, request_id=change_request_id) + if not request_to_review: + raise ProductNotFoundException( + f"ProductChangeRequest with ID {change_request_id} not found for review." + ) + + if request_to_review["Status"] != RequestStatusEnum.PENDING_APPROVAL.value: + logger.warning( + f"Invalid operation: Request {change_request_id} is not in PENDING_APPROVAL status (current: {request_to_review['Status']}). Cannot review." + ) + raise InvalidOperationException( + f"Request is not in PENDING_APPROVAL status, cannot be reviewed." + ) + + # 2. 调用 CRUD 更新状态和管理员信息 + updated_request_dict = self._pcr_crud.update_request_by_admin( + conn=db, + request_id=change_request_id, + status=review_data.Status.value, + admin_notes=review_data.AdminNotes, + admin_reviewer_id=admin_user.UserID, # Admin performing the review + actor_id=admin_user.UserID, + ) + + if not updated_request_dict: + logger.error( + f"Failed to update request {change_request_id} status by admin in CRUD layer." + ) + raise Exception("Failed to review and update request status.") + + logger.success( + f"ChangeRequestID {change_request_id} reviewed by Admin {admin_user.UserID}, new status: {review_data.Status.value}." + ) + + # 3. 如果审核通过 (APPROVED),调用 _apply_approved_request() 方法来应用请求 + if review_data.Status == RequestStatusEnum.APPROVED: + logger.info(f"Request {change_request_id} approved. Attempting to apply changes.") + # _apply_approved_request is now public: apply_approved_request + # The public apply_approved_request should handle fetching the request again + # and checking its status before applying. + # It also needs the applier_user (admin_user in this case). + try: + resp = await self.apply_approved_request( + db=db, + change_request_id=change_request_id, + applier_user=admin_user, # Admin is applying their own approval + ) + except Exception as e: + logger.error( + f"Failed to apply approved request {change_request_id} by Admin {admin_user.UserID}: {e}" + ) + # will return the *approved* but not applied request as response in the final return + else: + logger.success( + f"Approved request {change_request_id} applied successfully by Admin {admin_user.UserID}." + ) + # Optionally, return the response from apply_approved_request + return resp + + return ProductChangeRequestResponse(**updated_request_dict) async def apply_approved_request( self, db: Connection, *, change_request_id: int, - applier_user: CurrentUserSchema, # 执行应用的用户 + applier_user: CurrentUserSchema, ) -> ProductChangeRequestResponse: - """ - 应用一个状态为 'APPROVED' 的商品变更请求。请求类型是 PRODUCT_CREATE/UPDATE/DELETE。 - - 根据 RequestType 执行相应的商品操作 (创建、更新 Product 表中的记录)。 - - 如果商品操作成功,则将 ProductChangeRequest 的状态更新为 'APPLIED', - 并(如果是 PRODUCT_CREATE)回填 ProductChangeRequest.ProductID。 - - 返回转换后的 ProductChangeRequestResponse 对象。 - - :param db: 数据库连接。 - :param change_request_id: 已批准的变更请求ID。 - :param applier_user: 执行应用的用户 (商家或管理员)。商家只可以应用自己的请求。TODO: 后续使用权限检查类来处理 - :return: ProductChangeRequestResponse (状态为 APPLIED)。 - :raises InvalidOperationException: 如果请求状态不是 APPROVED。 - :raises StoreNotFoundException: (RequestNotFoundException) 如果请求未找到。 - :raises ProductNotFoundException: 如果是 UPDATE/DELETE,但目标商品不存在。 - :raises Exception: 如果商品操作或请求状态更新失败。 - """ logger.info( f"UserID {applier_user.UserID} attempting to apply approved ChangeRequestID {change_request_id}" ) - # 1. 获取请求,验证状态是 APPROVED。 - # a. 如果 applier_user 不是管理员,则验证 applier_user.UserID 是否等于请求的 MerchantUserID。 - # 2. 解析 ProposedData_JSON (如果 RequestType 是 CREATE 或 UPDATE)。 - # 3. 根据 RequestType: - # a. PRODUCT_CREATE: 调用 self._product_crud.create_product()。获取新 ProductID。 - # b. PRODUCT_UPDATE: 调用 self._product_crud.update_product()。 - # c. PRODUCT_DELETE: 调用 self._product_crud.delete_product()。 - # 4. 如果商品操作成功: - # a. 调用 self._pcr_crud.update_request_by_admin() (或一个专门的 _mark_request_applied 方法), - # 将状态设为 APPLIED,并(如果是 CREATE)回填 ProductID。 - # 5. 返回最终的请求状态。 - raise NotImplementedError - - - async def _apply_approved_request( - self, - db: Connection, - *, - change_request_id: int, - applier_user_id: int, # 执行应用的管理员用户ID - ) -> ProductChangeRequestResponse: - """ - (通常由管理员或后台任务调用) 应用一个状态为 'APPROVED' 的商品变更请求。 - - 根据 RequestType 执行相应的商品操作 (创建、更新、删除 Product 表中的记录)。 - - 如果商品操作成功,则将 ProductChangeRequest 的状态更新为 'APPLIED', - 并(如果是 PRODUCT_CREATE)回填 ProductChangeRequest.ProductID。 - - :param db: 数据库连接。 - :param change_request_id: 已批准的变更请求ID。 - :param applier_user_id: 执行应用的管理员用户ID/系统用户ID。留空表示系统自动应用。 - :return: ProductChangeRequestResponse (状态为 APPLIED)。 - :raises InvalidOperationException: 如果请求状态不是 APPROVED。 - :raises StoreNotFoundException: (RequestNotFoundException) 如果请求未找到。 - :raises ProductNotFoundException: 如果是 UPDATE/DELETE,但目标商品不存在。 - :raises Exception: 如果商品操作或请求状态更新失败。 - """ - logger.info( - f"Admin UserID {applier_user_id} attempting to apply approved ChangeRequestID {change_request_id}" + + # 1. 获取请求,验证状态是 APPROVED + pcr_data = self._pcr_crud.get_request_by_id( + conn=db, + request_id=change_request_id, + ) + if not pcr_data: + raise ProductNotFoundException( + f"ProductChangeRequest with ID {change_request_id} not found." + ) + + if pcr_data["Status"] != RequestStatusEnum.APPROVED.value: + logger.warning( + f"Cannot apply request {change_request_id}: Status is '{pcr_data['Status']}', not '{RequestStatusEnum.APPROVED.value}'." + ) + raise InvalidOperationException( + f"Request {change_request_id} is not in APPROVED status." + ) + + # 权限检查: 商家只能应用自己的,管理员可以应用任何已批准的 + # TODO: 替换为权限检查类的调用 + is_admin = applier_user.UserRole == "admin" + if not is_admin and pcr_data["MerchantUserID"] != applier_user.UserID: + logger.warning( + f"Permission Denied: UserID {applier_user.UserID} cannot apply request {change_request_id} owned by MerchantID {pcr_data['MerchantUserID']}." + ) + # raise PermissionDeniedException("You do not have permission to apply this request.") + + # 2. 解析 ProposedData_JSON + proposed_data_obj: Optional[ProposedProductData] = None + if pcr_data[ + "ProposedData_JSON" + ]: # ProposedData_JSON is already a dict due to CRUD's _deserialize + try: + proposed_data_obj = ProposedProductData(**pcr_data["ProposedData_JSON"]) + except Exception as e: # Pydantic ValidationError + logger.error( + f"Invalid ProposedData_JSON in ChangeRequestID {change_request_id}: {pcr_data['ProposedData_JSON']}. Error: {e}" + ) + # Optionally update request to REJECTED or a new FAILED_APPLICATION status + self._pcr_crud.update_request_by_admin( # Mark as rejected if data is bad + conn=db, + request_id=change_request_id, + status=RequestStatusEnum.REJECTED.value, + admin_reviewer_id=applier_user.UserID, + admin_notes=f"Application failed: Invalid proposed data. {e}", + actor_id=applier_user.UserID, + ) + raise BadRequestException(f"Invalid proposed data for request {change_request_id}.") + + applied_product_id: Optional[int] = pcr_data.get( + "ProductID" + ) # Existing ProductID for UPDATE/DELETE + + # 3. 根据 RequestType 执行商品操作 + request_type = RequestTypeEnum(pcr_data["RequestType"]) + + if request_type == RequestTypeEnum.PRODUCT_CREATE: + if applied_product_id is not None: + raise InvalidOperationException( + f"ProductID should be None for PRODUCT_CREATE request {change_request_id}." + ) + if not proposed_data_obj: + raise BadRequestException( + "ProposedData_JSON is required for PRODUCT_CREATE application." + ) + # Ensure all required fields for product creation are present in proposed_data_obj + # This validation was also in submit_new_request, but good to re-check or ensure consistency + if not all( + [ + proposed_data_obj.ProductName, + proposed_data_obj.Price, + proposed_data_obj.CategoryID, + ] + ): + raise BadRequestException( + "ProductName, Price, CategoryID are required in ProposedData for PRODUCT_CREATE." + ) + + created_product_dict = self._product_crud.create_product( + conn=db, + store_id=pcr_data["StoreID"], # StoreID from the request + category_id=proposed_data_obj.CategoryID, # type: ignore + product_name=proposed_data_obj.ProductName, # type: ignore + product_description=proposed_data_obj.ProductDescription, + price=proposed_data_obj.Price, # type: ignore + stock_quantity=proposed_data_obj.StockQuantity or 0, # Default to 0 if None + main_image_url=proposed_data_obj.MainImageURL, + actor_id=applier_user.UserID, + ) + if not created_product_dict: + raise Exception( + f"Failed to create product for ChangeRequestID {change_request_id}." + ) + applied_product_id = created_product_dict["ProductID"] + logger.info( + f"Product {applied_product_id} created for ChangeRequestID {change_request_id}." + ) + + elif request_type == RequestTypeEnum.PRODUCT_UPDATE: + if ( + applied_product_id is None + ): # Should have been set when creating PRODUCT_UPDATE request + raise InvalidOperationException( + f"ProductID is missing for PRODUCT_UPDATE request {change_request_id}." + ) + if not proposed_data_obj: + raise BadRequestException( + "ProposedData_JSON is required for PRODUCT_UPDATE application." + ) + + # Prepare kwargs for product_crud.update_product_fields + update_kwargs = proposed_data_obj.model_dump( + exclude_unset=True + ) # Only fields that were set + if not update_kwargs: + logger.info( + f"No fields to update in ProposedData_JSON for PRODUCT_UPDATE request {change_request_id}." + ) + else: + updated_product_dict = self._product_crud.update_product( + conn=db, + product_id=applied_product_id, + update_data=update_kwargs, + actor_id=applier_user.UserID, + ) + if not updated_product_dict: + raise Exception( + f"Failed to update product {applied_product_id} for ChangeRequestID {change_request_id}." + ) + logger.info( + f"Product {applied_product_id} updated for ChangeRequestID {change_request_id}." + ) + + elif request_type == RequestTypeEnum.PRODUCT_DELETE: + if applied_product_id is None: + raise InvalidOperationException( + f"ProductID is missing for PRODUCT_DELETE request {change_request_id}." + ) + + deleted_successfully = self._product_crud.delete_product( + conn=db, product_id=applied_product_id, actor_id=applier_user.UserID + ) + if not deleted_successfully: + # ProductCRUD.delete should ideally raise ProductNotFoundException if not found + raise Exception( + f"Failed to delete product {applied_product_id} for ChangeRequestID {change_request_id}." + ) + logger.info( + f"Product {applied_product_id} deleted for ChangeRequestID {change_request_id}." + ) + # For DELETE, applied_product_id remains the ID of the deleted product for record keeping. + + # 4. 更新 ProductChangeRequest 状态为 APPLIED 和 ProductID (如果创建) + final_pcr_dict = self._pcr_crud.update_request_applied( + conn=db, + request_id=change_request_id, + actor_id=applier_user.UserID, + ) + if not final_pcr_dict: + # This is a critical failure, means updating the PCR status failed + logger.error( + f"CRITICAL: Failed to update ChangeRequestID {change_request_id} to APPLIED after product operation." + ) + raise Exception( + f"Failed to finalize ChangeRequest {change_request_id} status to APPLIED." + ) + + logger.success( + f"ChangeRequestID {change_request_id} successfully applied by UserID {applier_user.UserID}." ) - # 1. 获取请求,验证状态是 APPROVED。 - # 2. 解析 ProposedData_JSON (如果 RequestType 是 CREATE 或 UPDATE)。 - # 3. 根据 RequestType: - # a. PRODUCT_CREATE: 调用 self._product_crud.create_product()。获取新 ProductID。 - # b. PRODUCT_UPDATE: 调用 self._product_crud.update_product()。 - # c. PRODUCT_DELETE: 调用 self._product_crud.delete_product()。 - # 4. 如果商品操作成功: - # a. 调用 self._pcr_crud.update_request_by_admin() (或一个专门的 _mark_request_applied 方法), - # 将状态设为 APPLIED,并(如果是 CREATE)回填 ProductID。 - # 5. 返回最终的请求状态。 - raise NotImplementedError + return ProductChangeRequestResponse(**final_pcr_dict) From e07e07fd468fd10778bd31b4a2685f871a2da80f Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 05:02:07 +0800 Subject: [PATCH 12/26] fix(schema): ProductChangeRequestCreate uses correct base --- src/backend/app/schemas/product_change_request_schema_v2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/app/schemas/product_change_request_schema_v2.py b/src/backend/app/schemas/product_change_request_schema_v2.py index baf02a9..2b91e3a 100644 --- a/src/backend/app/schemas/product_change_request_schema_v2.py +++ b/src/backend/app/schemas/product_change_request_schema_v2.py @@ -54,7 +54,7 @@ class ProductChangeRequestBase(BaseModel): SubmitterNotes: Optional[str] = Field(None, description="商家提交备注。") -class ProductChangeRequestCreate(BaseModel): +class ProductChangeRequestCreate(ProductChangeRequestBase): """ 用于 API 创建新的“商品变更请求”。 MerchantUserID 通常从请求上下文中获取。 @@ -112,7 +112,7 @@ class ProductChangeRequestResponse(ProductChangeRequestBase): API 返回单个商品变更请求信息时使用的数据模型。 """ # override ProductID - ProductID: int = Field(..., description="商品ID。") + ProductID: Optional[int] = Field(None, description="商品ID。对于创建请求,可能还没有商品ID。") ChangeRequestID: int = Field(..., description="变更请求的唯一ID。") MerchantUserID: int = Field(..., description="提交请求的商家UserID。") From 594f5bc99f9e914780565f0f190068178d815a60 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 05:02:48 +0800 Subject: [PATCH 13/26] test(product_change_request_service_v2): add unit tests for ProductChangeRequestService2 --- .../test_product_change_request_service_v2.py | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 src/backend/test/unit/service/test_product_change_request_service_v2.py diff --git a/src/backend/test/unit/service/test_product_change_request_service_v2.py b/src/backend/test/unit/service/test_product_change_request_service_v2.py new file mode 100644 index 0000000..3ae3744 --- /dev/null +++ b/src/backend/test/unit/service/test_product_change_request_service_v2.py @@ -0,0 +1,442 @@ +# src/backend/test/unit/service/test_product_change_request_service_v2.py +import unittest +from unittest.mock import MagicMock, AsyncMock, patch, ANY, call +import datetime +from decimal import Decimal +from typing import Dict, Any, Optional, List + +# 调整导入路径以匹配您的项目结构 +from backend.app.services.product_change_request_service_v2 import ProductChangeRequestService2 +from backend.app.crud.product_change_request_crud_v2 import ProductChangeRequestCRUD2 +from backend.app.crud.product_crud import ProductCRUD +from backend.app.crud.store_crud import StoreCRUD +from backend.app.crud.user_crud import UserCRUD +from backend.app.schemas.product_change_request_schema_v2 import ( + ProductChangeRequestCreate, + ProductChangeRequestResponse, + ProductChangeRequestListResponse, + ProductChangeRequestUpdateByAdmin, + ProductChangeRequestQueryParams, + ProductChangeRequestTypeApiEnum as RequestTypeEnum, + ProductChangeRequestStatusApiEnum as RequestStatusEnum, + ProposedProductData, +) +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.product_schema import ProductStatusApiEnum +from backend.app.utils.exceptions import ( + StoreNotFoundException, + ProductNotFoundException, + InvalidOperationException, + BadRequestException, +) + +from sqlalchemy.engine.base import Connection + + +class TestProductChangeRequestService2(unittest.IsolatedAsyncioTestCase): + + # --- Class-level shared data --- + merchant_user_id_cls: int = 1 + store_id_cls: int = 10 + product_id_cls: int = 100 + admin_user_id_cls: int = 999 + + mock_merchant_user_cls: CurrentUserSchema + mock_admin_user_cls: CurrentUserSchema + sample_store_data_cls: Dict[str, Any] + sample_product_data_cls: Dict[str, Any] + sample_pcr_data_cls: Dict[str, Any] + sample_approved_pcr_data_cls: Dict[str, Any] + + @classmethod + def setUpClass(cls): + # Initialize class-level mock users and sample data dicts + current_utc_time = datetime.datetime.utcnow() # Use utcnow for naive as in previous setup + cls.mock_merchant_user_cls = CurrentUserSchema( + UserID=cls.merchant_user_id_cls, + Username="merchant_cls", + Email="m_cls@store.com", + UserRole="merchant", + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + PhoneNumber=None, + ) + cls.mock_admin_user_cls = CurrentUserSchema( + UserID=cls.admin_user_id_cls, + Username="admin_cls", + Email="admin_cls@site.com", + UserRole="admin", + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + PhoneNumber=None, + ) + cls.sample_store_data_cls = { + "StoreID": cls.store_id_cls, + "OwnerUserID": cls.merchant_user_id_cls, + "StoreName": "Class Test Store", + } + cls.sample_product_data_cls = { + "ProductID": cls.product_id_cls, + "StoreID": cls.store_id_cls, + "ProductName": "Class Test Product", + } + cls.sample_pcr_data_cls = { + "ChangeRequestID": 1, + "MerchantUserID": cls.merchant_user_id_cls, + "StoreID": cls.store_id_cls, + "RequestType": RequestTypeEnum.PRODUCT_CREATE.value, + "ProposedData_JSON": { + "ProductName": "Class New", + "Price": Decimal("10.00"), + "CategoryID": 1, + }, + "Status": RequestStatusEnum.PENDING_APPROVAL.value, + "CreationTime": current_utc_time, + "LastUpdatedDate": current_utc_time + datetime.timedelta(minutes=5), + } + cls.sample_approved_pcr_data_cls = { + **cls.sample_pcr_data_cls, + "Status": RequestStatusEnum.APPROVED.value, + } + # No DB operations in unit test setUpClass + + def setUp(self): + self.mock_pcr_crud = MagicMock(spec=ProductChangeRequestCRUD2) + self.mock_product_crud = MagicMock(spec=ProductCRUD) + self.mock_store_crud = MagicMock(spec=StoreCRUD) + self.mock_user_crud = MagicMock(spec=UserCRUD) + + self.service = ProductChangeRequestService2( + pcr_crud=self.mock_pcr_crud, + product_crud=self.mock_product_crud, + store_crud=self.mock_store_crud, + user_crud=self.mock_user_crud, + ) + self.mock_db_conn = MagicMock(spec=Connection) + + # Use class-level mock users and data where appropriate, or create instance-specific if needed + self.merchant_user = self.mock_merchant_user_cls + self.admin_user = self.mock_admin_user_cls + + # --- Test submit_new_request --- + async def test_submit_new_request_create_success(self): + proposed_data = ProposedProductData( + ProductName="New Gadget", + Price=Decimal("99.99"), + CategoryID=1, + StockQuantity=10, + MainImageURL=None, + ProductStatus=None, + ProductDescription=None, + ) + request_in = ProductChangeRequestCreate( + StoreID=self.store_id_cls, + RequestType=RequestTypeEnum.PRODUCT_CREATE, + ProposedData_JSON=proposed_data, + SubmitterNotes="Please approve new gadget", + ) + self.mock_store_crud.get_store_by_id.return_value = self.sample_store_data_cls + + mock_created_pcr_dict = { + "ChangeRequestID": 123, + "MerchantUserID": self.merchant_user.UserID, + "StoreID": self.store_id_cls, + "RequestType": RequestTypeEnum.PRODUCT_CREATE.value, + "ProposedData_JSON": proposed_data.model_dump(exclude_unset=True), + "Status": RequestStatusEnum.PENDING_APPROVAL.value, + "SubmitterNotes": "Please approve new gadget", + "ProductID": None, + "CreationTime": datetime.datetime.utcnow(), + "LastUpdatedDate": datetime.datetime.utcnow(), + } + self.mock_pcr_crud.create_request_create_product.return_value = mock_created_pcr_dict + + response = await self.service.submit_new_request( + db=self.mock_db_conn, merchant_user=self.merchant_user, request_in=request_in + ) + + self.assertIsInstance(response, ProductChangeRequestResponse) + self.assertEqual(response.ChangeRequestID, 123) + self.assertEqual(response.RequestType, RequestTypeEnum.PRODUCT_CREATE) + self.mock_store_crud.get_store_by_id.assert_called_once_with( + conn=self.mock_db_conn, store_id=self.store_id_cls, actor_id=self.merchant_user.UserID + ) + self.mock_pcr_crud.create_request_create_product.assert_called_once_with( + conn=self.mock_db_conn, + merchant_user_id=self.merchant_user.UserID, + store_id=request_in.StoreID, + submitter_notes=request_in.SubmitterNotes, + proposed_data_json=proposed_data.model_dump(exclude_unset=True), + actor_id=self.merchant_user.UserID, + ) + + async def test_submit_new_request_update_success(self): + proposed_data = ProposedProductData(ProductName="Updated Gadget Name") + request_in = ProductChangeRequestCreate( + StoreID=self.store_id_cls, + RequestType=RequestTypeEnum.PRODUCT_UPDATE, + ProductID=self.product_id_cls, + ProposedData_JSON=proposed_data, + ) + self.mock_store_crud.get_store_by_id.return_value = self.sample_store_data_cls + self.mock_product_crud.get_product_by_id.return_value = { + **self.sample_product_data_cls, + "StoreID": self.store_id_cls, + } + + mock_created_pcr_dict = { + **self.sample_pcr_data_cls, # Use class sample as base + "ChangeRequestID": 124, # Different ID + "RequestType": RequestTypeEnum.PRODUCT_UPDATE.value, + "ProductID": self.product_id_cls, + "ProposedData_JSON": proposed_data.model_dump(exclude_unset=True), + } + self.mock_pcr_crud.create_request_update_product.return_value = mock_created_pcr_dict + + response = await self.service.submit_new_request( + db=self.mock_db_conn, merchant_user=self.merchant_user, request_in=request_in + ) + self.assertEqual(response.RequestType, RequestTypeEnum.PRODUCT_UPDATE) + self.mock_pcr_crud.create_request_update_product.assert_called_once() + + async def test_submit_new_request_delete_success(self): + request_in = ProductChangeRequestCreate( + StoreID=self.store_id_cls, + RequestType=RequestTypeEnum.PRODUCT_DELETE, + ProductID=self.product_id_cls, + ) + self.mock_store_crud.get_store_by_id.return_value = self.sample_store_data_cls + self.mock_product_crud.get_product_by_id.return_value = { + **self.sample_product_data_cls, + "StoreID": self.store_id_cls, + } + + mock_created_pcr_dict = { + **self.sample_pcr_data_cls, + "ChangeRequestID": 125, + "RequestType": RequestTypeEnum.PRODUCT_DELETE.value, + "ProductID": self.product_id_cls, + "ProposedData_JSON": None, + } + self.mock_pcr_crud.create_request_delete_product.return_value = mock_created_pcr_dict + + response = await self.service.submit_new_request( + db=self.mock_db_conn, merchant_user=self.merchant_user, request_in=request_in + ) + self.assertEqual(response.RequestType, RequestTypeEnum.PRODUCT_DELETE) + self.mock_pcr_crud.create_request_delete_product.assert_called_once() + + # ... (other tests for submit_new_request error paths remain similar) + + # --- Test get_request_details --- + async def test_get_request_details_success_owner(self): + change_request_id = self.sample_pcr_data_cls["ChangeRequestID"] + # Ensure the mock pcr data's MerchantUserID matches the actor + mock_pcr_for_owner = { + **self.sample_pcr_data_cls, + "MerchantUserID": self.merchant_user.UserID, + } + self.mock_pcr_crud.get_request_by_id.return_value = mock_pcr_for_owner + + response = await self.service.get_request_details( + db=self.mock_db_conn, change_request_id=change_request_id, actor_user=self.merchant_user + ) + self.assertIsInstance(response, ProductChangeRequestResponse) + self.assertEqual(response.ChangeRequestID, change_request_id) + self.mock_pcr_crud.get_request_by_id.assert_called_once_with( + conn=self.mock_db_conn, request_id=change_request_id + ) + + async def test_get_request_details_success_admin(self): + change_request_id = self.sample_pcr_data_cls["ChangeRequestID"] + # Admin can view request even if not owner + self.mock_pcr_crud.get_request_by_id.return_value = self.sample_pcr_data_cls + + response = await self.service.get_request_details( + db=self.mock_db_conn, + change_request_id=change_request_id, + actor_user=self.mock_admin_user_cls, # Admin actor + ) + self.assertEqual(response.ChangeRequestID, change_request_id) + + # ... (other tests for get_request_details error paths remain similar) + + # --- Test list_requests_for_merchant --- + async def test_list_requests_for_merchant_success(self): + query_params = ProductChangeRequestQueryParams(Status=[RequestStatusEnum.PENDING_APPROVAL]) + mock_requests_data = [ + {**self.sample_pcr_data_cls, "Status": RequestStatusEnum.PENDING_APPROVAL.value} + ] + self.mock_pcr_crud.get_request_list.return_value = mock_requests_data + + response = await self.service.list_requests_for_merchant( + db=self.mock_db_conn, merchant_user=self.merchant_user, query_params=query_params + ) + self.assertIsInstance(response, ProductChangeRequestListResponse) + self.assertEqual(response.TotalCount, 1) + self.mock_pcr_crud.get_request_list.assert_called_once_with( + conn=self.mock_db_conn, + merchant_user_id=self.merchant_user.UserID, + status=[RequestStatusEnum.PENDING_APPROVAL.value], + request_type=None, + store_id=None, + product_id=None, + ) + + # --- Test list_requests_for_admin --- + async def test_list_requests_for_admin_success(self): + query_params = ProductChangeRequestQueryParams(StoreID=self.store_id_cls) + self.mock_pcr_crud.get_request_list.return_value = [self.sample_pcr_data_cls] + + response = await self.service.list_requests_for_admin( + db=self.mock_db_conn, admin_user=self.mock_admin_user_cls, query_params=query_params + ) + self.assertEqual(response.TotalCount, 1) + self.mock_pcr_crud.get_request_list.assert_called_once_with( + conn=self.mock_db_conn, + status=None, + request_type=None, + store_id=self.store_id_cls, + product_id=None, + merchant_user_id=None, + ) + + # --- Test merchant_cancel_request --- + async def test_merchant_cancel_request_success(self): + change_request_id = self.sample_pcr_data_cls["ChangeRequestID"] + pending_request_owned = { + **self.sample_pcr_data_cls, + "MerchantUserID": self.merchant_user.UserID, + "Status": RequestStatusEnum.PENDING_APPROVAL.value, + } + self.mock_pcr_crud.get_request_by_id.side_effect = [ + pending_request_owned, + {**pending_request_owned, "Status": RequestStatusEnum.CANCELLED_BY_USER.value}, + ] + self.mock_pcr_crud.cancel_request.return_value = True + + response = await self.service.merchant_cancel_request( + db=self.mock_db_conn, + change_request_id=change_request_id, + merchant_user=self.merchant_user, + ) + self.assertEqual(response.Status, RequestStatusEnum.CANCELLED_BY_USER) + self.mock_pcr_crud.cancel_request.assert_called_once_with( + conn=self.mock_db_conn, request_id=change_request_id, actor_id=self.merchant_user.UserID + ) + + # ... (test_merchant_cancel_request_not_pending remains similar) ... + + # --- Test admin_review_request --- + async def test_admin_review_request_approve_and_trigger_apply(self): + change_request_id = self.sample_pcr_data_cls["ChangeRequestID"] + review_data = ProductChangeRequestUpdateByAdmin( + Status=RequestStatusEnum.APPROVED, AdminNotes="Looks good" + ) + + pending_request = { + **self.sample_pcr_data_cls, + "Status": RequestStatusEnum.PENDING_APPROVAL.value, + "RequestType": RequestTypeEnum.PRODUCT_CREATE.value, + "ProposedData_JSON": { + "ProductName": "Super Gadget", + "Price": Decimal("120.00"), + "CategoryID": 1, + "StockQuantity": 10, + }, + } + approved_request_from_crud_review = { + **pending_request, + "Status": RequestStatusEnum.APPROVED.value, + "AdminReviewerID": self.admin_user_id_cls, + "AdminNotes": "Looks good", + } + + self.mock_pcr_crud.get_request_by_id.return_value = pending_request + self.mock_pcr_crud.update_request_by_admin.return_value = approved_request_from_crud_review + + applied_pcr_response_mock_dict = { + **approved_request_from_crud_review, + "Status": RequestStatusEnum.APPLIED.value, + "ProductID": 501, + } + applied_pcr_response_mock = ProductChangeRequestResponse(**applied_pcr_response_mock_dict) + + with patch.object( + self.service, + "apply_approved_request", + AsyncMock(return_value=applied_pcr_response_mock), + ) as mock_apply_request: + response = await self.service.admin_review_request( + db=self.mock_db_conn, + change_request_id=change_request_id, + admin_user=self.mock_admin_user_cls, + review_data=review_data, + ) + + self.assertEqual(response.Status, RequestStatusEnum.APPLIED) + mock_apply_request.assert_called_once_with( + db=self.mock_db_conn, + change_request_id=change_request_id, + applier_user=self.mock_admin_user_cls, + ) + + # ... (test_admin_review_request_reject remains similar) ... + + # --- Test apply_approved_request --- + async def test_apply_approved_request_product_create_success(self): + change_request_id = self.sample_approved_pcr_data_cls[ + "ChangeRequestID" + ] # Use approved sample + applier_user = self.mock_admin_user_cls + + # Ensure the approved PCR data for create has ProductID as None initially + pcr_for_create_apply = { + **self.sample_approved_pcr_data_cls, # Already APPROVED + "RequestType": RequestTypeEnum.PRODUCT_CREATE.value, + "ProposedData_JSON": { + "ProductName": "Applied Prod", + "Price": Decimal("199.99"), + "CategoryID": 1, + "StockQuantity": 50, + }, + "ProductID": None, + } + self.mock_pcr_crud.get_request_by_id.return_value = pcr_for_create_apply + + created_product_from_db = { + "ProductID": 777, + "ProductName": "Applied Prod", + "Price": Decimal("199.99"), + "CategoryID": 1, + "StockQuantity": 50, + "StoreID": self.store_id_cls, + } + self.mock_product_crud.create_product.return_value = created_product_from_db + + final_applied_pcr_data = { + **pcr_for_create_apply, + "Status": RequestStatusEnum.APPLIED.value, + "ProductID": 777, + } + # SUT's apply_approved_request calls pcr_crud.update_request_by_admin to set APPLIED and ProductID + self.mock_pcr_crud.update_request_applied.return_value = final_applied_pcr_data + + response = await self.service.apply_approved_request( + db=self.mock_db_conn, change_request_id=change_request_id, applier_user=applier_user + ) + + self.assertEqual(response.Status, RequestStatusEnum.APPLIED) + self.assertEqual(response.ProductID, 777) + self.mock_product_crud.create_product.assert_called_once() + self.mock_pcr_crud.update_request_applied.assert_called_once_with( + conn=self.mock_db_conn, + request_id=change_request_id, + actor_id=applier_user.UserID, + ) + + # ... (test_apply_approved_request_not_approved_status remains similar) ... + + +if __name__ == "__main__": + unittest.main(verbosity=2) From d1c9df218e4a6939122c33e7f9653f36f675ec15 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 05:44:41 +0800 Subject: [PATCH 14/26] feat(dependencies): add ProductChangeRequestCRUD2 and ProductChangeRequestService2 dependencies --- src/backend/app/crud/__init__.py | 1 + src/backend/app/dependencies/crud_deps.py | 10 ++++- src/backend/app/dependencies/service_deps.py | 42 ++++++++++++++++++-- src/backend/app/services/__init__.py | 2 + 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/backend/app/crud/__init__.py b/src/backend/app/crud/__init__.py index 11b40d2..6531cd9 100644 --- a/src/backend/app/crud/__init__.py +++ b/src/backend/app/crud/__init__.py @@ -11,3 +11,4 @@ from .order_crud import OrderCRUD from .payment_transaction_crud import PaymentTransactionCRUD from .store_crud import StoreCRUD +from .product_change_request_crud_v2 import ProductChangeRequestCRUD2 diff --git a/src/backend/app/dependencies/crud_deps.py b/src/backend/app/dependencies/crud_deps.py index 8d31b1e..1c76d8e 100644 --- a/src/backend/app/dependencies/crud_deps.py +++ b/src/backend/app/dependencies/crud_deps.py @@ -8,7 +8,8 @@ OrderCRUD, OrderItemCRUD, PaymentTransactionCRUD, - StoreCRUD + StoreCRUD, + ProductChangeRequestCRUD2, ) @@ -83,3 +84,10 @@ def get_store_crud() -> StoreCRUD: :return: The StoreCRUD instance. """ return StoreCRUD.get_instance() + +def get_product_change_request_crud2() -> ProductChangeRequestCRUD2: + """ + Dependency to get the ProductChangeRequestCRUD2 instance. + :return: The ProductChangeRequestCRUD2 instance. + """ + return ProductChangeRequestCRUD2.get_instance() diff --git a/src/backend/app/dependencies/service_deps.py b/src/backend/app/dependencies/service_deps.py index 26da3da..53819cb 100644 --- a/src/backend/app/dependencies/service_deps.py +++ b/src/backend/app/dependencies/service_deps.py @@ -7,10 +7,23 @@ from backend.app.crud.user_session_crud import UserSessionCRUD, user_session_crud_instance from backend.app.schemas.auth_schema import TokenPayload from backend.app.services.permission_checker import PermissionChecker -from backend.app.utils.security import hash_password, verify_password, decode_access_token, create_access_token +from backend.app.utils.security import ( + hash_password, + verify_password, + decode_access_token, + create_access_token, +) from backend.app.services import ( - UserService, AuthService, CartService, AddressService, ProductService, OrderService, - StoreChangeRequestService, ProductChangeRequestService, StoreService + UserService, + AuthService, + CartService, + AddressService, + ProductService, + OrderService, + StoreChangeRequestService, + ProductChangeRequestService, + StoreService, + ProductChangeRequestService2, ) from backend.app.dependencies.crud_deps import * from backend.app.crud.store_change_request_crud import get_store_change_request_crud_instance @@ -177,3 +190,26 @@ def get_store_service( store_crud=store_crud, user_crud=user_crud, ) + + +# dependency injection for ChangeRequestService +def get_product_change_request_service_v2( + product_change_request_crud: ProductChangeRequestCRUD2 = Depends(get_product_change_request_crud2), + product_crud: ProductCRUD = Depends(get_product_crud), + user_crud: UserCRUD = Depends(get_user_crud), + store_crud: StoreCRUD = Depends(get_store_crud), +) -> ProductChangeRequestService2: + """ + Dependency to get the ProductChangeRequestService2 instance. + :param product_change_request_crud: The ProductChangeRequestCRUD2 instance. + :param product_crud: The ProductCRUD instance. + :param user_crud: The UserCRUD instance. + :param store_crud: The StoreCRUD instance. + :return: The ProductChangeRequestService2 instance. + """ + return ProductChangeRequestService2( + pcr_crud=product_change_request_crud, + product_crud=product_crud, + user_crud=user_crud, + store_crud=store_crud, + ) diff --git a/src/backend/app/services/__init__.py b/src/backend/app/services/__init__.py index a8af441..85d2010 100644 --- a/src/backend/app/services/__init__.py +++ b/src/backend/app/services/__init__.py @@ -7,3 +7,5 @@ from .store_change_request_service import StoreChangeRequestService from .product_change_request_service import ProductChangeRequestService from .store_service import StoreService + +from .product_change_request_service_v2 import ProductChangeRequestService2 From 75a515355a34aa0e9de946f81720732f91847a73 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 05:45:38 +0800 Subject: [PATCH 15/26] feat(endpoint/product.c.r): implement v2 endpoints for product_change_request_v2 --- .../v1/endpoints/product_change_request_v2.py | 362 +++++++++++++----- 1 file changed, 273 insertions(+), 89 deletions(-) diff --git a/src/backend/app/api/v1/endpoints/product_change_request_v2.py b/src/backend/app/api/v1/endpoints/product_change_request_v2.py index 7363b96..ca58b4c 100644 --- a/src/backend/app/api/v1/endpoints/product_change_request_v2.py +++ b/src/backend/app/api/v1/endpoints/product_change_request_v2.py @@ -6,9 +6,8 @@ # 依赖项导入 from backend.app.dependencies.auth_deps import get_current_active_user, get_db_connection -# 服务层依赖通常在实际实现中注入,这里仅为签名占位 -# from backend.app.services.product_change_request_service import ProductChangeRequestService -# from backend.app.dependencies.service_deps import get_product_change_request_service +from backend.app.services.product_change_request_service_v2 import ProductChangeRequestService2 +from backend.app.dependencies.service_deps import get_product_change_request_service_v2 # Schema 导入 - 使用您指定的 v2 文件名和更新后的类名 from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema @@ -23,6 +22,8 @@ from sqlalchemy.engine.base import Connection from backend.app.utils import logger # 假设的 logger +from backend.app.utils.exceptions import StoreNotFoundException, ProductNotFoundException, BadRequestException, \ + PermissionDeniedException router = APIRouter() @@ -35,46 +36,71 @@ summary="商家提交新的商品变更请求" ) async def submit_product_change_request( - request_in: ProductChangeRequestCreate, - current_user: CurrentUserSchema = Depends(get_current_active_user), - db: Connection = Depends(get_db_connection) - # service: ProductChangeRequestService = Depends(get_product_change_request_service) # 服务层依赖 + request_in: ProductChangeRequestCreate, + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), + service: ProductChangeRequestService2 = Depends(get_product_change_request_service_v2), ): """ 商家用户提交一个新的商品变更请求(创建、更新或删除商品)。 `MerchantUserID` 将从 `current_user` 中获取。 """ logger.info( - f"User {current_user.UserID} submitting product change request: {request_in.model_dump(exclude_unset=True)}") - - return ProductChangeRequestResponse( - ProductID=1, - StoreID=100, - RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, - SubmitterNotes="Test note", - ChangeRequestID=300, - MerchantUserID=500, - ProposedData_JSON={"key": "value"}, - Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, - AdminReviewerID=None, - ReviewTimestamp=None, - AdminNotes=None, - CreationTime=datetime.datetime.now(), - LastUpdatedDate=datetime.datetime.now() + f"User {current_user.UserID} submitting product change request: {request_in.model_dump(exclude_unset=True)}" ) + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + # 这里调用服务层的创建方法 + change_request = await service.submit_new_request( + db, + request_in=request_in, + merchant_user=current_user, + ) + return change_request + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {e}", + ) + except ProductNotFoundException as e: + logger.error(f"Product not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product not found: {e}", + ) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Bad request: {e}", + ) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + ) + @router.get( "/list/", response_model=ProductChangeRequestListResponse, tags=["Product Change Requests"], - summary="查询商品变更请求列表 (可按状态等筛选)" + summary="商家查询自己的商品变更请求列表 (可按状态等筛选)" ) async def list_product_change_requests( query_params: ProductChangeRequestQueryParams = Depends(), current_user: CurrentUserSchema = Depends(get_current_active_user), - db: Connection = Depends(get_db_connection) - # service: ProductChangeRequestService = Depends(get_product_change_request_service) + db: Connection = Depends(get_db_connection), + service: ProductChangeRequestService2 = Depends(get_product_change_request_service_v2) ): """ 获取商品变更请求列表。 @@ -83,29 +109,106 @@ async def list_product_change_requests( - 查询参数通过 `ProductChangeRequestQueryParams` Pydantic 模型接收。 - **注意**: 您之前提到查询不需要分页。 """ - logger.info( - f"User {current_user.UserID} listing product change requests with filters: {query_params.model_dump(exclude_none=True)}") - - return ProductChangeRequestListResponse( - Requests=[ - ProductChangeRequestResponse( - ProductID=1, - StoreID=100, - RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, - SubmitterNotes="Test note", - ChangeRequestID=300, - MerchantUserID=500, - ProposedData_JSON={"key": "value"}, - Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, - AdminReviewerID=None, - ReviewTimestamp=None, - AdminNotes=None, - CreationTime=datetime.datetime.now(), - LastUpdatedDate=datetime.datetime.now() - )], - TotalCount=1 - ) + logger.info(f"User {current_user.UserID} listing product change requests with filters: {query_params.model_dump(exclude_unset=True)}") + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + # 这里调用服务层的创建方法 + change_request = await service.list_requests_for_merchant( + db, + query_params=query_params, + merchant_user=current_user + ) + return change_request + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {e}", + ) + except ProductNotFoundException as e: + logger.error(f"Product not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product not found: {e}", + ) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Bad request: {e}", + ) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + ) +@router.get( + "/list-admin/", + response_model=ProductChangeRequestListResponse, + tags=["Product Change Requests (Admin)"], + summary="管理员商品变更请求列表 (可按状态等筛选)" +) +async def list_product_change_requests_admin( + query_params: ProductChangeRequestQueryParams = Depends(), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), + service: ProductChangeRequestService2 = Depends(get_product_change_request_service_v2) +): + """ + 获取商品变更请求列表。 + - 商家用户通常只能看到自己提交的请求。 + - 管理员用户可以查看所有请求,并使用更多筛选条件。 + - 查询参数通过 `ProductChangeRequestQueryParams` Pydantic 模型接收。 + - **注意**: 您之前提到查询不需要分页。 + """ + logger.info(f"Admin {current_user.UserID} fetching all product change requests with filters: {query_params.model_dump(exclude_unset=True)}") + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + # 这里调用服务层的创建方法 + change_request = await service.list_requests_for_admin( + db, + query_params=query_params, + admin_user=current_user + ) + return change_request + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {e}", + ) + except ProductNotFoundException as e: + logger.error(f"Product not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product not found: {e}", + ) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Bad request: {e}", + ) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + ) @router.get( "/{change_request_id}", @@ -116,30 +219,53 @@ async def list_product_change_requests( async def get_product_change_request_details( change_request_id: int = FastApiPath(..., description="要检索的变更请求的唯一ID", gt=0), current_user: CurrentUserSchema = Depends(get_current_active_user), - db: Connection = Depends(get_db_connection) - # service: ProductChangeRequestService = Depends(get_product_change_request_service) + db: Connection = Depends(get_db_connection), + service: ProductChangeRequestService2 = Depends(get_product_change_request_service_v2) ): """ 根据 ChangeRequestID 获取单个商品变更请求的详细信息。 服务层需要验证当前用户是否有权查看此请求。 """ logger.info(f"User {current_user.UserID} fetching details for ProductChangeRequestID: {change_request_id}") - - return ProductChangeRequestResponse( - ProductID=1, - StoreID=100, - RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, - SubmitterNotes="Test note", - ChangeRequestID=300, - MerchantUserID=500, - ProposedData_JSON={"key": "value"}, - Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, - AdminReviewerID=None, - ReviewTimestamp=None, - AdminNotes=None, - CreationTime=datetime.datetime.now(), - LastUpdatedDate=datetime.datetime.now() - ) + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + # 这里调用服务层的创建方法 + change_request = await service.get_request_details( + db, + change_request_id=change_request_id, + actor_user=current_user + ) + return change_request + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {e}", + ) + except ProductNotFoundException as e: + logger.error(f"Product not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product not found: {e}", + ) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Bad request: {e}", + ) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + ) @router.post( @@ -152,8 +278,8 @@ async def admin_review_product_change_request( review_data: ProductChangeRequestUpdateByAdmin, change_request_id: int = FastApiPath(..., description="要审核的变更请求的唯一ID", gt=0), admin_user: CurrentUserSchema = Depends(get_current_active_user), - db: Connection = Depends(get_db_connection) - # service: ProductChangeRequestService = Depends(get_product_change_request_service) + db: Connection = Depends(get_db_connection), + service: ProductChangeRequestService2 = Depends(get_product_change_request_service_v2) ): """ 管理员审核商品变更请求,可以将其状态更新为 'APPROVED' 或 'REJECTED',并添加审核备注。 @@ -161,24 +287,44 @@ async def admin_review_product_change_request( # 实际应用中,admin_user 应通过专门的 get_current_admin_user 依赖注入,该依赖会进行权限检查 logger.info( f"Admin {admin_user.UserID} reviewing ProductChangeRequestID: {change_request_id} with review: {review_data.model_dump(exclude_unset=True)}") - - return ProductChangeRequestResponse( - ProductID=1, - StoreID=100, - RequestType=ProductChangeRequestTypeApiEnum.PRODUCT_CREATE, - SubmitterNotes="Test note", - ChangeRequestID=300, - MerchantUserID=500, - ProposedData_JSON={"key": "value"}, - Status=ProductChangeRequestStatusApiEnum.PENDING_APPROVAL, - AdminReviewerID=None, - ReviewTimestamp=None, - AdminNotes=None, - CreationTime=datetime.datetime.now(), - LastUpdatedDate=datetime.datetime.now() - ) - - + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + # 这里调用服务层的创建方法 + change_request = await service.admin_review_request( + db, + change_request_id=change_request_id, admin_user=admin_user, review_data=review_data + ) + return change_request + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {e}", + ) + except ProductNotFoundException as e: + logger.error(f"Product not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product not found: {e}", + ) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Bad request: {e}", + ) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + ) @router.delete( "/{change_request_id}", @@ -189,8 +335,8 @@ async def admin_review_product_change_request( async def delete_product_change_request( change_request_id: int = FastApiPath(..., description="要删除的变更请求的唯一ID", gt=0), current_user: CurrentUserSchema = Depends(get_current_active_user), - db: Connection = Depends(get_db_connection) - # service: ProductChangeRequestService = Depends(get_product_change_request_service) + db: Connection = Depends(get_db_connection), + service: ProductChangeRequestService2 = Depends(get_product_change_request_service_v2) ): """ 删除一个商品变更请求。 @@ -201,4 +347,42 @@ async def delete_product_change_request( logger.warning( f"User {current_user.UserID} attempting to DELETE ProductChangeRequestID: {change_request_id}. Business logic for deletion needs clarification.") - return {"message": "Deletion logic not implemented. Please clarify the business rules."} + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + # 这里调用服务层的创建方法 + change_request = await service.merchant_cancel_request( + db, + change_request_id=change_request_id, + merchant_user=current_user + ) + return change_request + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {e}", + ) + except ProductNotFoundException as e: + logger.error(f"Product not found: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product not found: {e}", + ) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Bad request: {e}", + ) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred.", + ) From 1985d5784ebafc9a2e77ad1bee8e83778292582d Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 06:25:26 +0800 Subject: [PATCH 16/26] feat(product_change_request_crud_v2): add Decimal handling and custom JSON encoder --- .../crud/product_change_request_crud_v2.py | 30 ++++++++++++++----- src/backend/app/utils/json.py | 14 +++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 src/backend/app/utils/json.py diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py index c3c35bb..dcd4c71 100644 --- a/src/backend/app/crud/product_change_request_crud_v2.py +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -1,4 +1,5 @@ # src/backend/app/crud/product_change_request_crud_v2.py +from decimal import Decimal from typing import Optional, List, Dict, Any from sqlalchemy import Connection, text, exc from loguru import logger @@ -9,6 +10,8 @@ # 假设这些枚举在您的 schema 文件中定义并可导入 from backend.app.schemas.product_change_request_schema_v2 import \ ProductChangeRequestTypeApiEnum as TypeEnum, ProductChangeRequestStatusApiEnum as StatusEnum +from backend.app.utils.json import DecimalEncoder + class ProductChangeRequestCRUD2: """ @@ -62,9 +65,22 @@ def _deserialize_proposed_data(data: Optional[Any]) -> Optional[Dict[str, Any]]: def _serialize_proposed_data(data: Optional[Dict[str, Any]]) -> Optional[str]: """辅助方法:将 ProposedData_JSON 字典序列化为字符串。""" if isinstance(data, dict): - return json.dumps(data) + return json.dumps(data, cls=DecimalEncoder) # 使用自定义的 DecimalEncoder return data # 已经是字符串或 None + def _handle_result_dict(self, row_dict: Dict[str, Any]) -> Dict[str, Any]: + """ + 处理查询结果字典,反序列化 ProposedData_JSON 字段,处理Decimal类型 + :param row_dict: + :return: + """ + row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) + data_dict = row_dict.get("ProposedData_JSON") + # convert string to decimal if needed + if data_dict and "Price" in data_dict: + data_dict["Price"] = Decimal(data_dict["Price"]) + return row_dict + def get_request_by_id(self, conn: Connection, *, request_id: int) -> Optional[Dict[str, Any]]: """ 根据请求ID获取商品变更请求 @@ -84,8 +100,7 @@ def get_request_by_id(self, conn: Connection, *, request_id: int) -> Optional[Di result = conn.execute(select_stmt, {"ChangeRequestID": request_id}).fetchone() if result: row_dict = dict(result._mapping) # type: ignore - row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) - return row_dict + return self._handle_result_dict(row_dict) return None except Exception as e: logger.error(f"Error getting ProductChangeRequest by ID {request_id}: {e}") @@ -113,8 +128,7 @@ def get_request_by_id_for_owner(self, conn: Connection, *, request_id: int, merc {"ChangeRequestID": request_id, "MerchantUserID": merchant_user_id}).fetchone() if result: row_dict = dict(result._mapping) # type: ignore - row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) - return row_dict + return self._handle_result_dict(row_dict) return None except Exception as e: logger.error( @@ -196,9 +210,9 @@ def get_request_list( try: results = conn.execute(select_stmt, params).fetchall() processed_results = [] - for row in results: - row_dict = dict(row._mapping) # type: ignore - row_dict["ProposedData_JSON"] = self._deserialize_proposed_data(row_dict.get("ProposedData_JSON")) + for result in results: + row_dict = dict(result._mapping) # type: ignore + row_dict = self._handle_result_dict(row_dict) processed_results.append(row_dict) return processed_results except Exception as e: diff --git a/src/backend/app/utils/json.py b/src/backend/app/utils/json.py new file mode 100644 index 0000000..8795543 --- /dev/null +++ b/src/backend/app/utils/json.py @@ -0,0 +1,14 @@ +# src/backend/app/utils/json.py +# utils for serializing and deserializing some objects to/from JSON +import json +from decimal import Decimal + +class DecimalEncoder(json.JSONEncoder): + """ + Custom JSON encoder for Decimal objects. + """ + + def default(self, obj): + if isinstance(obj, Decimal): + return str(obj) # Convert Decimal to string + return super().default(obj) # Call the superclass method for other types From a2a62e2350d60bd15986d930d2d803021857d7be Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 17:29:28 +0800 Subject: [PATCH 17/26] feat(product_change_request_crud_v2): add optional new_product_id to update_request_applied --- .../crud/product_change_request_crud_v2.py | 40 +++++++++++++------ .../product_change_request_service_v2.py | 1 + .../test_product_change_request_service_v2.py | 1 + 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/backend/app/crud/product_change_request_crud_v2.py b/src/backend/app/crud/product_change_request_crud_v2.py index dcd4c71..689c325 100644 --- a/src/backend/app/crud/product_change_request_crud_v2.py +++ b/src/backend/app/crud/product_change_request_crud_v2.py @@ -496,6 +496,7 @@ def update_request_applied( conn: Connection, *, request_id: int, + new_product_id: Optional[int] = None, # 新商品ID,如果创建了新商品,需要回填 actor_id: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ @@ -504,6 +505,7 @@ def update_request_applied( TODO: test me :param conn: :param request_id: + :param new_product_id: 新商品ID,如果是创建商品请求,可能需要回填 :param actor_id: :return: """ @@ -511,22 +513,34 @@ def update_request_applied( f"ActorID {actor_id} updating ChangeRequestID {request_id} to Status 'APPLIED'.") self._set_actor_session_variable(conn, actor_id) - update_stmt = text(f""" - UPDATE {self.table_name} - SET Status = :applied_status - WHERE ChangeRequestID = :request_id AND Status = :approved_status - """) + # update_stmt = text(f""" + # UPDATE {self.table_name} + # SET Status = :applied_status + # WHERE ChangeRequestID = :request_id AND Status = :approved_status + # """) + + # set the update_stmt and params. new_product_id is optional, so we handle it conditionally + set_clauses: List[str] = ["Status = :applied_status"] + applied_status = StatusEnum.APPLIED + approved_status = StatusEnum.APPROVED + params: Dict[str, Any] = { + "applied_status": applied_status, + "request_id": request_id, + "approved_status": approved_status + } + if new_product_id is not None: + set_clauses.append("ProductID = :new_product_id") + params["new_product_id"] = new_product_id + update_stmt_str = (f"UPDATE {self.table_name}" + f" SET {', '.join(set_clauses)}" + f" WHERE ChangeRequestID = :request_id AND Status = :approved_status") + update_stmt = text(update_stmt_str) + + # LastUpdatedDate 会由数据库的 ON UPDATE CURRENT_TIMESTAMP 自动处理 try: - applied_status = StatusEnum.APPLIED - approved_status = StatusEnum.APPROVED - - result = conn.execute(update_stmt, { - "applied_status": applied_status, - "request_id": request_id, - "approved_status": approved_status - }) + result = conn.execute(update_stmt, params) if result.rowcount > 0: logger.info(f"ChangeRequestID {request_id} status updated to {applied_status} by ActorID {actor_id}.") diff --git a/src/backend/app/services/product_change_request_service_v2.py b/src/backend/app/services/product_change_request_service_v2.py index c8ba34a..5b5e43c 100644 --- a/src/backend/app/services/product_change_request_service_v2.py +++ b/src/backend/app/services/product_change_request_service_v2.py @@ -629,6 +629,7 @@ async def apply_approved_request( final_pcr_dict = self._pcr_crud.update_request_applied( conn=db, request_id=change_request_id, + new_product_id=applied_product_id if request_type == RequestTypeEnum.PRODUCT_CREATE else None, actor_id=applier_user.UserID, ) if not final_pcr_dict: diff --git a/src/backend/test/unit/service/test_product_change_request_service_v2.py b/src/backend/test/unit/service/test_product_change_request_service_v2.py index 3ae3744..7be2249 100644 --- a/src/backend/test/unit/service/test_product_change_request_service_v2.py +++ b/src/backend/test/unit/service/test_product_change_request_service_v2.py @@ -432,6 +432,7 @@ async def test_apply_approved_request_product_create_success(self): self.mock_pcr_crud.update_request_applied.assert_called_once_with( conn=self.mock_db_conn, request_id=change_request_id, + new_product_id=777, # 回填新创建的产品ID actor_id=applier_user.UserID, ) From 2be8ee839b954e4e4c47deb5f535041240bced20 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 17:39:30 +0800 Subject: [PATCH 18/26] feat(product_change_request_service_v2): enhance logging and add integration tests for endpoints --- .../product_change_request_service_v2.py | 4 +- ...est_product_change_request_endpoints_v2.py | 493 ++++++++++++++++++ 2 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py diff --git a/src/backend/app/services/product_change_request_service_v2.py b/src/backend/app/services/product_change_request_service_v2.py index 5b5e43c..63ec322 100644 --- a/src/backend/app/services/product_change_request_service_v2.py +++ b/src/backend/app/services/product_change_request_service_v2.py @@ -528,7 +528,7 @@ async def apply_approved_request( # 3. 根据 RequestType 执行商品操作 request_type = RequestTypeEnum(pcr_data["RequestType"]) - + logger.info(f"Applying ChangeRequestID {change_request_id} of type {request_type.value}.") if request_type == RequestTypeEnum.PRODUCT_CREATE: if applied_product_id is not None: raise InvalidOperationException( @@ -550,7 +550,7 @@ async def apply_approved_request( raise BadRequestException( "ProductName, Price, CategoryID are required in ProposedData for PRODUCT_CREATE." ) - + logger.info(f"Calling ProductCRUD to create new product for ChangeRequestID {change_request_id}.") created_product_dict = self._product_crud.create_product( conn=db, store_id=pcr_data["StoreID"], # StoreID from the request diff --git a/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py b/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py new file mode 100644 index 0000000..cdea403 --- /dev/null +++ b/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py @@ -0,0 +1,493 @@ +# src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py +import json +import unittest +import datetime +import uuid +from decimal import Decimal +from typing import Dict, Any, Optional, List +import asyncio + +from fastapi import FastAPI, Depends, status +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.engine.base import Connection + +# Adjust import paths to match your project structure +from backend.test.base_db_testcase import AsyncBaseDBTestCaseAutoRollback +from backend.app.main import app # Your FastAPI main application instance +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.product_change_request_schema_v2 import ( + ProductChangeRequestCreate, + ProductChangeRequestResponse, + ProductChangeRequestListResponse, + ProductChangeRequestUpdateByAdmin, + ProductChangeRequestQueryParams, + ProductChangeRequestStatusApiEnum as RequestStatusEnum, + ProductChangeRequestTypeApiEnum as RequestTypeEnum, + ProposedProductData, +) +from backend.app.services.product_change_request_service_v2 import ProductChangeRequestService2 +from backend.app.crud.user_crud import UserCRUD +from backend.app.crud.store_crud import StoreCRUD +from backend.app.crud.product_crud import ProductCRUD +from backend.app.crud.product_change_request_crud_v2 import ( + ProductChangeRequestCRUD2 as PCR_CRUD, +) +from backend.app.dependencies.auth_deps import get_current_active_user, get_db_connection +from backend.app.dependencies.service_deps import ( + get_product_change_request_service_v2 as get_pcr_service, +) # ⭐ 使用正确的服务依赖函数名 + +from backend.app.utils.security import hash_password # ⭐ 使用 hash_password +from backend.app.utils.exceptions import ( + StoreNotFoundException, + ProductNotFoundException, + BadRequestException, +) +from backend.app.core import database as core_db_module +from backend.app.utils import logger + + +class TestProductChangeRequestEndpointsIntegration(AsyncBaseDBTestCaseAutoRollback): + + # --- Class-level shared data IDs --- + merchant_user_id_cls: int = 20 + merchant_username_cls: str = "pcr_merchant1_integ" + merchant_email_cls: str = "pcrmerchant1_integ@example.com" + merchant_password_plain_cls: str = "MerchantPassInteg1!" + merchant_password_hash_cls: str = hash_password(merchant_password_plain_cls) + + admin_user_id_cls: int = 21 + admin_username_cls: str = "pcr_admin_integ" + admin_email_cls: str = "pcradmin_integ@example.com" + admin_password_plain_cls: str = "AdminPassInteg1!" + admin_password_hash_cls: str = hash_password(admin_password_plain_cls) + + store_id_cls: int = 2001 + store_name_cls: str = "PCR Integ Test Store" + + category_id_cls: int = 2101 + product2_id_cls: int = 2202 + product2_price_cls: Decimal = Decimal("199.99") + + @classmethod + def _create_shared_class_data(cls, conn: Connection): + logger.info(f"--- {cls.__name__}: Creating shared class-level PCR data ---") + try: + users_data = [ + { + "UserID": cls.merchant_user_id_cls, + "Username": cls.merchant_username_cls, + "PasswordHash": cls.merchant_password_hash_cls, + "Email": cls.merchant_email_cls, + "UserRole": "merchant", + }, + { + "UserID": cls.admin_user_id_cls, + "Username": cls.admin_username_cls, + "PasswordHash": cls.admin_password_hash_cls, + "Email": cls.admin_email_cls, + "UserRole": "admin", + }, + ] + for ud in users_data: + conn.execute( + text( + "INSERT INTO User (UserID, Username, PasswordHash, Email, UserRole, AccountStatus) VALUES (:UserID, :Username, :PasswordHash, :Email, :UserRole, 'ACTIVE') ON DUPLICATE KEY UPDATE Username=VALUES(Username)" + ), + ud, + ) + conn.execute( + text( + "INSERT INTO ProductCategory (CategoryID, CategoryName) VALUES (:id, 'PCR Integ Test Category') ON DUPLICATE KEY UPDATE CategoryName=VALUES(CategoryName)" + ), + {"id": cls.category_id_cls}, + ) + conn.execute( + text( + "INSERT INTO Store (StoreID, StoreName, OwnerUserID) VALUES (:id, :name, :owner_id) ON DUPLICATE KEY UPDATE StoreName=VALUES(StoreName)" + ), + { + "id": cls.store_id_cls, + "name": cls.store_name_cls, + "owner_id": cls.merchant_user_id_cls, + }, + ) + conn.execute( + text( + "INSERT INTO Product (ProductID, ProductName, Price, StoreID, CategoryID, StockQuantity) VALUES (:id, :name, :price, :sid, :cid, 50) ON DUPLICATE KEY UPDATE ProductName=VALUES(ProductName)" + ), + { + "id": cls.product2_id_cls, + "name": "Existing Product for PCR Integ", + "price": cls.product2_price_cls, + "sid": cls.store_id_cls, + "cid": cls.category_id_cls, + }, + ) + logger.info(f"--- {cls.__name__}: Shared class-level PCR data creation complete ---") + except Exception as e: + logger.error(f"ERROR during {cls.__name__}._create_shared_class_data: {e}") + raise + + @classmethod + def setUpClass(cls): + super().setUpClass() + conn_for_class_setup: Optional[Connection] = None + try: + conn_for_class_setup = cls.engine.connect() + with conn_for_class_setup.begin(): + cls._create_shared_class_data(conn_for_class_setup) + except Exception as e: + logger.error(f"ERROR in {cls.__name__}.setUpClass during shared data setup: {e}") + raise + finally: + if conn_for_class_setup: + conn_for_class_setup.close() + + async def asyncSetUp(self): + await super().asyncSetUp() + + current_utc_time = datetime.datetime.now(datetime.timezone.utc) + self.mock_merchant_user_schema = CurrentUserSchema( + UserID=self.merchant_user_id_cls, + Username=self.merchant_username_cls, + Email=self.merchant_email_cls, + PhoneNumber=None, + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + UserRole="merchant", + ) + self.mock_admin_user_schema = CurrentUserSchema( + UserID=self.admin_user_id_cls, + Username=self.admin_username_cls, + Email=self.admin_email_cls, + PhoneNumber=None, + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + UserRole="admin", + ) + + def override_get_db_connection() -> Connection: + return self.connection + + self.real_pcr_crud = PCR_CRUD.get_instance() + self.real_product_crud = ProductCRUD.get_instance() + self.real_store_crud = StoreCRUD.get_instance() + self.real_user_crud = UserCRUD.get_instance() + + self.real_pcr_service = ProductChangeRequestService2( + pcr_crud=self.real_pcr_crud, + product_crud=self.real_product_crud, + store_crud=self.real_store_crud, + user_crud=self.real_user_crud, + ) + + def override_get_pcr_service() -> ( + ProductChangeRequestService2 + ): # ⭐ 使用正确的服务依赖函数名 + return self.real_pcr_service + + async def override_get_current_active_user_default() -> CurrentUserSchema: + return self.mock_merchant_user_schema + + app.dependency_overrides[get_db_connection] = override_get_db_connection + app.dependency_overrides[get_pcr_service] = ( + override_get_pcr_service # ⭐ 使用正确的服务依赖函数名 + ) + app.dependency_overrides[get_current_active_user] = override_get_current_active_user_default + + self.client = TestClient(app) + + async def asyncTearDown(self): + app.dependency_overrides.clear() + await super().asyncTearDown() + + # ⭐ 定义 _create_direct_pcr 辅助方法 + async def _create_direct_pcr( + self, + request_type: RequestTypeEnum, + merchant_id: int, # 明确指定创建者 + store_id: Optional[int] = None, + proposed_data: Optional[ProposedProductData] = None, + product_id: Optional[int] = None, + submitter_notes: Optional[str] = None, + status: RequestStatusEnum = RequestStatusEnum.PENDING_APPROVAL, # 允许设置初始状态 + ) -> Dict[str, Any]: + """ + Helper to create a ProductChangeRequest directly using the service for test setup. + This ensures service-level validations are hit. + Returns the dictionary representation of the created/updated PCR. + """ + if proposed_data is None: + proposed_data = ProposedProductData( + ProductName="Default Product Name", + Price=Decimal("99.99"), + CategoryID=self.category_id_cls, + StockQuantity=10, + MainImageURL=None, + ProductDescription="Default product description for integration test.", + ) + + pcr_create_schema = ProductChangeRequestCreate( + StoreID=store_id if store_id is not None else self.store_id_cls, + RequestType=request_type, + ProposedData_JSON=proposed_data, + SubmitterNotes=submitter_notes, + ProductID=product_id, + ) + + # Determine which mock user to use based on merchant_id for the service call + mock_user_for_service_call = self.mock_merchant_user_schema + if merchant_id == self.admin_user_id_cls: # Should not happen for submit_new_request + mock_user_for_service_call = self.mock_admin_user_schema + elif merchant_id != self.merchant_user_id_cls: + # If a different merchant ID is passed, we might need to create a mock user for them + # For simplicity, this helper assumes merchant_id will usually be self.merchant_user_id_cls + # or that the service call is fine with the default mock_merchant_user_schema. + # This part might need adjustment if you test PCRs for various merchants extensively. + logger.warning( + f"Creating PCR for MerchantID {merchant_id} using default mock user {self.mock_merchant_user_schema.UserID}" + ) + + created_pcr_resp: ProductChangeRequestResponse + # The service method itself should handle the transaction for its operations. + # The connection used by the service will be self.connection due to DI override. + created_pcr_resp = await self.real_pcr_service.submit_new_request( + db=self.connection, + merchant_user=mock_user_for_service_call, + request_in=pcr_create_schema, + ) + + created_pcr_dict = created_pcr_resp.model_dump() + + if ( + status != RequestStatusEnum.PENDING_APPROVAL + and created_pcr_dict.get("Status") != status.value + ): + # If a specific status other than default PENDING_APPROVAL is needed for setup, + # update it directly via CRUD (simulating an admin action for setup simplicity). + # This bypasses service layer status transition logic for setup. + updated_pcr_after_status_change = self.real_pcr_crud.update_request_by_admin( + conn=self.connection, + request_id=created_pcr_dict["ChangeRequestID"], + status=status.value, + admin_reviewer_id=self.admin_user_id_cls, + admin_notes=f"Status set to {status.value} by test setup helper _create_direct_pcr", + actor_id=self.admin_user_id_cls, + ) + if not updated_pcr_after_status_change: + self.fail( + f"Failed to update PCR status to {status.value} in _create_direct_pcr for request ID {created_pcr_dict['ChangeRequestID']}" + ) + return updated_pcr_after_status_change + + return created_pcr_dict + + # --- I. POST / (submit_product_change_request) --- + async def test_submit_pcr_product_create_success(self): + payload = ProductChangeRequestCreate( + StoreID=self.store_id_cls, + RequestType=RequestTypeEnum.PRODUCT_CREATE, + ProposedData_JSON=ProposedProductData( + ProductName="Awesome New Gadget API Integ", + ProductDescription="This is an amazing gadget for testing.", + Price=Decimal("139.99"), # Different price + CategoryID=self.category_id_cls, + StockQuantity=55, + MainImageURL=None, + ProductStatus=None, + ), + SubmitterNotes="Please approve this amazing gadget for integration test!", + ProductID=None, + + ) + response = self.client.post( + "/api/v1/product-change/", json=payload.model_dump(mode="json") + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + data = response.json() + self.assertEqual(data["RequestType"], RequestTypeEnum.PRODUCT_CREATE.value) + self.assertEqual(data["Status"], RequestStatusEnum.PENDING_APPROVAL.value) + self.assertEqual(data["MerchantUserID"], self.merchant_user_id_cls) + self.assertIsNotNone(data["ProposedData_JSON"]) + self.assertEqual(data["ProposedData_JSON"]["ProductName"], "Awesome New Gadget API Integ") + + # ... (rest of your test methods, ensuring they use the corrected _create_direct_pcr helper + # and class-level attributes like self.product2_id_cls, self.store_id_cls etc.) + + async def test_submit_pcr_product_update_success(self): + payload = ProductChangeRequestCreate( + StoreID=self.store_id_cls, + RequestType=RequestTypeEnum.PRODUCT_UPDATE, + ProductID=self.product2_id_cls, + ProposedData_JSON=ProposedProductData(Price=Decimal("188.88"), StockQuantity=42), + SubmitterNotes="Price and stock update for integ test.", + ) + response = self.client.post( + "/api/v1/product-change/", json=payload.model_dump(mode="json") + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + data = response.json() + self.assertEqual(data["RequestType"], RequestTypeEnum.PRODUCT_UPDATE.value) + self.assertEqual(data["ProductID"], self.product2_id_cls) + self.assertEqual(Decimal(data["ProposedData_JSON"]["Price"]), Decimal("188.88")) + + # --- II. GET /list/ (list_product_change_requests by merchant) --- + async def test_list_pcr_for_merchant_success_filters_by_status(self): + await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_CREATE, + merchant_id=self.merchant_user_id_cls, + status=RequestStatusEnum.PENDING_APPROVAL, + ) + await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_UPDATE, + product_id=self.product2_id_cls, + merchant_id=self.merchant_user_id_cls, + status=RequestStatusEnum.PENDING_APPROVAL, + ) + + response = self.client.get( + f"/api/v1/product-change/list/?Status={RequestStatusEnum.PENDING_APPROVAL.value}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + logger.debug(f"List PCR response data: {json.dumps(data, indent=2)}") + self.assertGreaterEqual(data["TotalCount"], 1) + self.assertTrue( + all(req["MerchantUserID"] == self.merchant_user_id_cls for req in data["Requests"]) + ) + self.assertTrue( + all( + req["Status"] == RequestStatusEnum.PENDING_APPROVAL.value + for req in data["Requests"] + ) + ) + + # --- III. GET /list-admin/ (list_product_change_requests by admin) --- + async def test_list_pcr_for_admin_can_filter_by_merchant(self): + app.dependency_overrides[get_current_active_user] = lambda: self.mock_admin_user_schema + + await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_CREATE, merchant_id=self.merchant_user_id_cls + ) + + response = self.client.get( + f"/api/v1/product-change/list-admin/?MerchantUserID={self.merchant_user_id_cls}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertGreaterEqual(data["TotalCount"], 1) + self.assertTrue( + all(req["MerchantUserID"] == self.merchant_user_id_cls for req in data["Requests"]) + ) + + # --- IV. GET /{change_request_id} --- + async def test_get_pcr_details_success_owner(self): + created_pcr = await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_CREATE, merchant_id=self.merchant_user_id_cls + ) + pcr_id = created_pcr["ChangeRequestID"] + + response = self.client.get(f"/api/v1/product-change/{pcr_id}") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertEqual(data["ChangeRequestID"], pcr_id) + self.assertEqual(data["MerchantUserID"], self.merchant_user_id_cls) + + # --- V. POST /{change_request_id}/review (Admin Review) --- + async def test_admin_review_request_approve_and_apply_create_success(self): + app.dependency_overrides[get_current_active_user] = lambda: self.mock_admin_user_schema + + proposed_data = ProposedProductData( + ProductName="Gadget Alpha API Integ", + Price=Decimal("250.00"), + CategoryID=self.category_id_cls, + StockQuantity=30, + ) + pcr_to_approve = await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_CREATE, + merchant_id=self.merchant_user_id_cls, + store_id=self.store_id_cls, + proposed_data=proposed_data, + status=RequestStatusEnum.PENDING_APPROVAL, + ) + pcr_id = pcr_to_approve["ChangeRequestID"] + logger.debug(f"Created PCR for approval: {pcr_to_approve}") + review_payload = ProductChangeRequestUpdateByAdmin( + Status=RequestStatusEnum.APPROVED, + AdminNotes="Approved for creation via API by integ test.", + ) + response = self.client.post( + f"/api/v1/product-change/{pcr_id}/review", + json=review_payload.model_dump(mode="json"), + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertEqual(data["Status"], RequestStatusEnum.APPLIED.value) + self.assertIsNotNone(data["ProductID"]) # Ensure product was created + new_product_id = data["ProductID"] + + product_db = self.real_product_crud.get_product_by_id( + self.connection, product_id=new_product_id + ) + self.assertIsNotNone(product_db) + self.assertEqual(product_db["ProductName"], "Gadget Alpha API Integ") + self.assertEqual(product_db["StoreID"], self.store_id_cls) + + # --- VI. DELETE /{change_request_id} (Merchant Cancel) --- + + async def test_delete_request_cancels_by_merchant_success(self): + # Current user is merchant1 (default override) + created_pcr = await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_CREATE, + merchant_id=self.merchant_user_id_cls, + proposed_data=ProposedProductData( + ProductName="Temporary Product for Cancellation Test", + ProductDescription="This product is created for testing cancellation.", + Price=Decimal("123.00"), + CategoryID=123, # Example category ID + ), + status=RequestStatusEnum.PENDING_APPROVAL, + ) + pcr_id = created_pcr["ChangeRequestID"] + + response = self.client.delete(f"/api/v1/product-change/{pcr_id}") + self.assertEqual( + response.status_code, + status.HTTP_204_NO_CONTENT, + response.text if response.content else "No Content", + ) + + pcr_db = self.real_pcr_crud.get_request_by_id(self.connection, request_id=pcr_id) + self.assertIsNotNone(pcr_db) + self.assertEqual(pcr_db["Status"], RequestStatusEnum.CANCELLED_BY_USER.value) + + async def test_delete_request_merchant_cannot_cancel_approved(self): + approved_pcr = await self._create_direct_pcr( + request_type=RequestTypeEnum.PRODUCT_CREATE, + merchant_id=self.merchant_user_id_cls, + proposed_data=ProposedProductData( + ProductName="Temporary Product for Cancellation Test", + ProductDescription="This product is created for testing cancellation.", + Price=Decimal("123.00"), + CategoryID=123, # Example category ID + ), + status=RequestStatusEnum.APPROVED, + ) + pcr_id = approved_pcr["ChangeRequestID"] + + response = self.client.delete(f"/api/v1/product-change/{pcr_id}") + # Endpoint calls service.merchant_cancel_request which raises InvalidOperationException + # Endpoint catches this and should return an appropriate HTTP error. + # Based on your endpoint: it catches generic Exception and returns 500. + # If it caught InvalidOperationException specifically, it could return 400. + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR, response.text) + # If endpoint was updated to catch InvalidOperationException: + # self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.text) + # self.assertIn("not in PENDING_APPROVAL status, cannot be cancelled", response.json()["detail"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 7f07d5181e36bf28e54f79ff17d363eb689020fa Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 20:23:25 +0800 Subject: [PATCH 19/26] refactor(product_change_request_v2): remove tags from endpoints and update router prefixes --- .../v1/endpoints/product_change_request_v2.py | 6 ------ src/backend/app/api/v1/router.py | 4 ++-- .../test_product_change_request_endpoints_v2.py | 16 ++++++++-------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/backend/app/api/v1/endpoints/product_change_request_v2.py b/src/backend/app/api/v1/endpoints/product_change_request_v2.py index ca58b4c..a9e4b55 100644 --- a/src/backend/app/api/v1/endpoints/product_change_request_v2.py +++ b/src/backend/app/api/v1/endpoints/product_change_request_v2.py @@ -32,7 +32,6 @@ "/", response_model=ProductChangeRequestResponse, status_code=status.HTTP_201_CREATED, - tags=["Product Change Requests"], summary="商家提交新的商品变更请求" ) async def submit_product_change_request( @@ -93,7 +92,6 @@ async def submit_product_change_request( @router.get( "/list/", response_model=ProductChangeRequestListResponse, - tags=["Product Change Requests"], summary="商家查询自己的商品变更请求列表 (可按状态等筛选)" ) async def list_product_change_requests( @@ -153,7 +151,6 @@ async def list_product_change_requests( @router.get( "/list-admin/", response_model=ProductChangeRequestListResponse, - tags=["Product Change Requests (Admin)"], summary="管理员商品变更请求列表 (可按状态等筛选)" ) async def list_product_change_requests_admin( @@ -213,7 +210,6 @@ async def list_product_change_requests_admin( @router.get( "/{change_request_id}", response_model=ProductChangeRequestResponse, - tags=["Product Change Requests"], summary="获取单个商品变更请求的详情" ) async def get_product_change_request_details( @@ -271,7 +267,6 @@ async def get_product_change_request_details( @router.post( "/{change_request_id}/review", response_model=ProductChangeRequestResponse, - tags=["Product Change Requests (Admin)"], summary="管理员审核商品变更请求" ) async def admin_review_product_change_request( @@ -329,7 +324,6 @@ async def admin_review_product_change_request( @router.delete( "/{change_request_id}", status_code=status.HTTP_204_NO_CONTENT, - tags=["Product Change Requests"], summary="删除商品变更请求 (如果业务逻辑允许)" ) async def delete_product_change_request( diff --git a/src/backend/app/api/v1/router.py b/src/backend/app/api/v1/router.py index 596c1fc..924fd81 100644 --- a/src/backend/app/api/v1/router.py +++ b/src/backend/app/api/v1/router.py @@ -15,7 +15,7 @@ api_router_v1.include_router(payment.router, prefix="/payment", tags=["Payments"]) api_router_v1.include_router(store.router, prefix="/store", tags=["Store"]) api_router_v1.include_router(store_change_request.router, prefix="/store-change", tags=["Store Change Requests"]) -api_router_v1.include_router(product_change_request.router, prefix="/product-change-deprecated", tags=["Product Change Requests"]) +api_router_v1.include_router(product_change_request.router, prefix="/product-change", tags=["Product Change Requests V1"]) -api_router_v1.include_router(product_change_request_v2.router, prefix="/product-change", tags=["Product Change Requests V2"]) +api_router_v1.include_router(product_change_request_v2.router, prefix="/product-change-new", tags=["Product Change Requests V2"]) diff --git a/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py b/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py index cdea403..f910c55 100644 --- a/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py +++ b/src/backend/test/integration/api_endpoints/test_product_change_request_endpoints_v2.py @@ -303,7 +303,7 @@ async def test_submit_pcr_product_create_success(self): ) response = self.client.post( - "/api/v1/product-change/", json=payload.model_dump(mode="json") + "/api/v1/product-change-new/", json=payload.model_dump(mode="json") ) self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) data = response.json() @@ -325,7 +325,7 @@ async def test_submit_pcr_product_update_success(self): SubmitterNotes="Price and stock update for integ test.", ) response = self.client.post( - "/api/v1/product-change/", json=payload.model_dump(mode="json") + "/api/v1/product-change-new/", json=payload.model_dump(mode="json") ) self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) data = response.json() @@ -348,7 +348,7 @@ async def test_list_pcr_for_merchant_success_filters_by_status(self): ) response = self.client.get( - f"/api/v1/product-change/list/?Status={RequestStatusEnum.PENDING_APPROVAL.value}" + f"/api/v1/product-change-new/list/?Status={RequestStatusEnum.PENDING_APPROVAL.value}" ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) data = response.json() @@ -373,7 +373,7 @@ async def test_list_pcr_for_admin_can_filter_by_merchant(self): ) response = self.client.get( - f"/api/v1/product-change/list-admin/?MerchantUserID={self.merchant_user_id_cls}" + f"/api/v1/product-change-new/list-admin/?MerchantUserID={self.merchant_user_id_cls}" ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) data = response.json() @@ -389,7 +389,7 @@ async def test_get_pcr_details_success_owner(self): ) pcr_id = created_pcr["ChangeRequestID"] - response = self.client.get(f"/api/v1/product-change/{pcr_id}") + response = self.client.get(f"/api/v1/product-change-new/{pcr_id}") self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) data = response.json() self.assertEqual(data["ChangeRequestID"], pcr_id) @@ -419,7 +419,7 @@ async def test_admin_review_request_approve_and_apply_create_success(self): AdminNotes="Approved for creation via API by integ test.", ) response = self.client.post( - f"/api/v1/product-change/{pcr_id}/review", + f"/api/v1/product-change-new/{pcr_id}/review", json=review_payload.model_dump(mode="json"), ) @@ -453,7 +453,7 @@ async def test_delete_request_cancels_by_merchant_success(self): ) pcr_id = created_pcr["ChangeRequestID"] - response = self.client.delete(f"/api/v1/product-change/{pcr_id}") + response = self.client.delete(f"/api/v1/product-change-new/{pcr_id}") self.assertEqual( response.status_code, status.HTTP_204_NO_CONTENT, @@ -478,7 +478,7 @@ async def test_delete_request_merchant_cannot_cancel_approved(self): ) pcr_id = approved_pcr["ChangeRequestID"] - response = self.client.delete(f"/api/v1/product-change/{pcr_id}") + response = self.client.delete(f"/api/v1/product-change-new/{pcr_id}") # Endpoint calls service.merchant_cancel_request which raises InvalidOperationException # Endpoint catches this and should return an appropriate HTTP error. # Based on your endpoint: it catches generic Exception and returns 500. From 2edd26a0341779aba787e92323cb1703cd87a0c7 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 21:04:50 +0800 Subject: [PATCH 20/26] feat(store_change_request_v2): implement new endpoints and schemas for store change requests --- .../v1/endpoints/store_change_request_v2.py | 163 +++++++++++++++ .../schemas/store_change_request_schema_v2.py | 195 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 src/backend/app/api/v1/endpoints/store_change_request_v2.py create mode 100644 src/backend/app/schemas/store_change_request_schema_v2.py diff --git a/src/backend/app/api/v1/endpoints/store_change_request_v2.py b/src/backend/app/api/v1/endpoints/store_change_request_v2.py new file mode 100644 index 0000000..59a859c --- /dev/null +++ b/src/backend/app/api/v1/endpoints/store_change_request_v2.py @@ -0,0 +1,163 @@ +# src/backend/app/api/v1/endpoints/store_change_request_v2.py +import datetime + +from fastapi import APIRouter, Depends, HTTPException, status, Path as FastApiPath, Query +from typing import List, Optional + +# 依赖项导入 +from backend.app.dependencies.auth_deps import get_current_active_user, get_db_connection + +# Schema 导入 - 使用您指定的 v2 文件名和更新后的类名 +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.store_change_request_schema_v2 import ( + StoreChangeRequestCreate, + StoreChangeRequestResponse, + StoreChangeRequestListResponse, + StoreChangeRequestUpdateRequestByAdmin, + StoreChangeRequestQueryParams, + StoreStatusEnum, + StoreChangeRequestStatusEnum as RequestStatusEnum, + StoreChangeRequestTypeEnum as RequestTypeEnum, + ProposedStoreData, +) + +from sqlalchemy.engine.base import Connection +from backend.app.utils import logger +from backend.app.utils.exceptions import StoreNotFoundException, BadRequestException, \ + PermissionDeniedException + +_mock_response: StoreChangeRequestResponse = StoreChangeRequestResponse( + ChangeRequestID=1, + StoreID=123, + RequestingUserID=456, + RequestType=RequestTypeEnum.STORE_CREATE, + Status=RequestStatusEnum.PENDING_APPROVAL, + SubmitterNotes="Initial store creation request.", + ProposedData_JSON=ProposedStoreData( + StoreName="Test Store", + Description="A test store for demonstration purposes.", + LogoURL="http://example.com/logo.png", + StoreStatus=StoreStatusEnum.ACTIVE + ), + AdminReviewerID=1, + AdminNotes="Initial review by admin.", + ReviewTimestamp=datetime.datetime.now(), + CreationTime=datetime.datetime.now(), + LastUpdatedDate=datetime.datetime.now() +) + +router = APIRouter() +@router.post( + "/", + response_model=StoreChangeRequestResponse, + status_code=status.HTTP_201_CREATED, + summary="创建店铺变更请求", +) +async def submit_store_change_request( + request_in: StoreChangeRequestCreate, + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), +): + """ + 创建一个新的店铺变更请求。 + 需要提供店铺 ID 和变更类型(创建、更新或删除)。 + """ + logger.info(f"User {current_user.UserID} is submitting a store change request: {request_in}") + + return _mock_response + +@router.get( + "/{request_id}", + response_model=StoreChangeRequestResponse, + summary="获取单个店铺变更请求", +) +async def get_store_change_request( + request_id: int = FastApiPath(..., description="变更请求的唯一ID"), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), +): + """ + 获取单个店铺变更请求的详细信息。 + """ + logger.info(f"User {current_user.UserID} is retrieving store change request with ID {request_id}") + + return _mock_response + +@router.get( + "/list/", + response_model=StoreChangeRequestListResponse, + summary="商家获取店铺变更请求列表", +) +async def list_store_change_requests( + query_params: StoreChangeRequestQueryParams = Depends(), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), +): + """ + 获取店铺变更请求列表,可以按状态、类型等筛选。 + """ + logger.info(f"User {current_user.UserID} is listing store change requests with filters: {query_params}") + + return StoreChangeRequestListResponse( + Requests=[_mock_response], + TotalCount=1 + ) + +@router.get( + "/list-admin/", + response_model=StoreChangeRequestListResponse, + summary="管理员获取店铺变更请求列表", +) +async def list_store_change_requests_admin( + query_params: StoreChangeRequestQueryParams = Depends(), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), +): + """ + 管理员获取店铺变更请求列表,可以按状态、类型等筛选。 + """ + logger.info(f"Admin {current_user.UserID} is listing store change requests with filters: {query_params}") + + return StoreChangeRequestListResponse( + Requests=[_mock_response], + TotalCount=1 + ) + +@router.delete( + "/{request_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除/取消店铺变更请求", +) +async def cancel_store_change_request( + request_id: int = FastApiPath(..., description="变更请求的唯一ID"), + current_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), +): + """ + 删除或取消一个店铺变更请求。 + 只有提交请求的用户或管理员可以执行此操作。 + """ + logger.info(f"User {current_user.UserID} is cancelling store change request with ID {request_id}") + + # 模拟删除操作 + return _mock_response + + +@router.put( + "/{request_id}/review/", + response_model=StoreChangeRequestResponse, + summary="管理员审核店铺变更请求", +) +async def review_store_change_request( + review_data: StoreChangeRequestUpdateRequestByAdmin, + request_id: int = FastApiPath(..., description="变更请求的唯一ID"), + admin_user: CurrentUserSchema = Depends(get_current_active_user), + db: Connection = Depends(get_db_connection), +): + """ + 管理员审核店铺变更请求,更新状态和备注。 + """ + logger.info(f"Admin {admin_user.UserID} is reviewing store change request with ID {request_id}") + + # 模拟审核操作 + return _mock_response diff --git a/src/backend/app/schemas/store_change_request_schema_v2.py b/src/backend/app/schemas/store_change_request_schema_v2.py new file mode 100644 index 0000000..3c17696 --- /dev/null +++ b/src/backend/app/schemas/store_change_request_schema_v2.py @@ -0,0 +1,195 @@ +# src/backend/app/schemas/store_change_request_schema_v2.py +from pydantic import BaseModel, Field, model_validator +from typing import Optional, Self +from enum import Enum +import datetime + +from .store_schema import StoreStatusEnum + + +class StoreChangeRequestTypeEnum(str, Enum): + """ + 店铺变更请求类型枚举。 + 会被 StoreChangeRequestResponse, StoreChangeRequestCreate, StoreChangeRequestUpdateRequest 使用。 + """ + + STORE_CREATE = "STORE_CREATE" # 创建店铺 + STORE_UPDATE = "STORE_UPDATE" # 更新店铺 + STORE_DELETE = "STORE_DELETE" # 删除店铺 + + +class StoreChangeRequestStatusEnum(str, Enum): + """ + 店铺变更请求状态枚举。 + 会被 StoreChangeRequestResponse, StoreChangeRequestCreate, StoreChangeRequestUpdateRequest 使用。 + """ + + PENDING_APPROVAL = "PENDING_APPROVAL" # 待审核 + APPROVED = "APPROVED" # 已批准 + REJECTED = "REJECTED" # 已拒绝 + APPLIED = "APPLIED" # 已应用到数据库 + CANCELLED_BY_USER = "CANCELLED_BY_USER" # 用户取消 + + +class ProposedStoreData(BaseModel): + """ + 用于店铺创建或更新时,在 ProposedData_JSON 中建议的店铺数据。 + - 对于 STORE_CREATE: + - 对于 STORE_UPDATE: 所有字段都是可选的,表示要更新的店铺属性。 + - 对于 STORE_DELETE: 该对象通常为空或未提供。 + """ + + StoreName: Optional[str] = Field( + None, max_length=255, description="店铺名称。对于创建请求是必需的。" + ) + Description: Optional[str] = Field(None, description="店铺描述。对于创建请求可选。") + LogoURL: Optional[str] = Field( + None, max_length=512, description="店铺 Logo 的 URL。对于创建请求可选。" + ) + StoreStatus: Optional[StoreStatusEnum] = Field( + None, + description="店铺状态。对于商家可以是 ACTIVE,INACTIVE_BY_MERCHANT。对于创建请求可选。", + ) + + +# --- StoreChangeRequest 创建和修改相关 Schemas --- + + +class StoreChangeRequestBase(BaseModel): + """ + 店铺变更请求的基础模型。 + 包含所有店铺变更请求共有的字段。 + """ + RequestType: StoreChangeRequestTypeEnum = Field( + ..., + description="请求类型(创建店铺、更新店铺或删除店铺)。", + ) + ProposedData_JSON: Optional[ProposedStoreData] = Field( + None, + description="建议的店铺数据。对于 STORE_CREATE 请求通常是必需的,其他请求可选。", + ) + SubmitterNotes: Optional[str] = Field( + None, + description="商家提交备注。可选。", + ) + + +class StoreChangeRequestCreate(StoreChangeRequestBase): + """ + 用于 API 创建新的“店铺变更请求”。 + RequestingUserID 通常从请求上下文中获取。 + """ + + StoreID: Optional[int] = Field( + None, + description="如果是 STORE_UPDATE 或 STORE_DELETE,则提供店铺ID。", + ) + + @model_validator(mode="after") + def validate_request_create(self) -> Self: + """ + 校验 ProposedData_JSON 和其他字段的逻辑。 + - 如果 RequestType 是 STORE_CREATE,则不能提供店铺ID, ProposedData_JSON 必须提供所有必要字段(StoreName) + - 如果 RequestType 是 STORE_UPDATE,则 ProposedData_JSON 可以为空或部分字段可选,StoreID 必须提供 + - 如果 RequestType 是 STORE_DELETE,则 ProposedData_JSON 不应提供,StoreID 必须提供 + """ + store_id: Optional[int] = self.StoreID + request_type: StoreChangeRequestTypeEnum = self.RequestType + proposed_data: Optional[ProposedStoreData] = self.ProposedData_JSON + if request_type == StoreChangeRequestTypeEnum.STORE_CREATE: + if store_id is not None: + raise ValueError("STORE_CREATE 请求不应提供 StoreID。") + if proposed_data is None or not proposed_data.StoreName: + raise ValueError("STORE_CREATE 请求必须提供 ProposedData_JSON 和 StoreName。") + elif request_type == StoreChangeRequestTypeEnum.STORE_UPDATE: + if store_id is None: + raise ValueError("STORE_UPDATE 请求必须提供 StoreID。") + if proposed_data is None: + raise ValueError("STORE_UPDATE 请求必须提供 ProposedData_JSON。") + elif request_type == StoreChangeRequestTypeEnum.STORE_DELETE: + if store_id is None: + raise ValueError("STORE_DELETE 请求必须提供 StoreID。") + if proposed_data is not None: + raise ValueError("STORE_DELETE 请求不应提供 ProposedData_JSON。") + return self + + +class StoreChangeRequestUpdateRequestByAdmin(BaseModel): + """ + 用于管理员审核时更新店铺变更请求的模型。 + 包含管理员审核相关的字段。 + """ + + Status: StoreChangeRequestStatusEnum = Field( + ..., + description="管理员审核后的状态(例如 APPROVED, REJECTED)。", + ) + AdminNotes: Optional[str] = Field( + None, + description="管理员审核备注。可选。", + ) + + +# 不设置用户“修改”请求的端点,只支持删除已有店铺的请求。故没有更多的修改请求模型。 + +# 响应 + + +class StoreChangeRequestResponse(BaseModel): + """ + API 返回单个店铺变更请求信息时使用的数据模型。 + """ + + ChangeRequestID: int = Field(..., description="变更请求的唯一ID。") + StoreID: Optional[int] = Field(None, description="目标店铺ID。对于 STORE_CREATE 请求可能为空。") + RequestingUserID: int = Field(..., description="提交请求的用户ID。") + RequestType: StoreChangeRequestTypeEnum = Field( + ..., description="请求类型(创建、更新或删除店铺)。" + ) + ProposedData_JSON: Optional[ProposedStoreData] = Field( + None, description="建议的数据体 (原始JSON)。" + ) + Status: StoreChangeRequestStatusEnum = Field(..., description="请求的当前状态。") + SubmitterNotes: Optional[str] = Field(None, description="商家提交备注。可选。") + AdminReviewerID: Optional[int] = Field(None, description="审核请求的管理员UserID。") + ReviewTimestamp: Optional[datetime.datetime] = Field(None, description="审核时间。") + AdminNotes: Optional[str] = Field(None, description="管理员审核备注。") + CreationTime: datetime.datetime = Field(..., description="请求创建时间。") + LastUpdatedDate: datetime.datetime = Field(..., description="请求最后更新时间。") + + +class StoreChangeRequestListResponse(BaseModel): + """ + API 返回店铺变更请求列表时使用的数据模型。 + """ + + Requests: list[StoreChangeRequestResponse] = Field(..., description="店铺变更请求列表。") + TotalCount: int = Field(..., description="符合条件的请求总数。") + + +# 查询 + + +class StoreChangeRequestQueryParams(BaseModel): + """ + 用于 API 端点查询店铺变更请求的参数模型。 + 按照一个或多个状态筛选;按请求类型筛选;按店铺ID筛选;按商家ID筛选。 + 所有字段都是可选的查询参数。 + """ + + Status: Optional[list[StoreChangeRequestStatusEnum]] = Field( + default=None, + description="按一个或多个状态筛选,例如 ['PENDING_APPROVAL', 'APPROVED']", + ) + RequestType: Optional[StoreChangeRequestTypeEnum] = Field( + default=None, + description="按请求类型筛选", + ) + StoreID: Optional[int] = Field( + default=None, + description="按店铺ID筛选", + ) + MerchantUserID: Optional[int] = Field( + default=None, + description="按商家ID筛选", + ) From b0398fd8ba224f8fda2cf48057a815c3bc9606a6 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 21:38:02 +0800 Subject: [PATCH 21/26] feat(store_change_request_crud_v2): add CRUD operations for store change requests --- .../app/crud/store_change_request_crud_v2.py | 465 ++++++++++++++++++ .../crud/test_store_change_request_crud_v2.py | 364 ++++++++++++++ 2 files changed, 829 insertions(+) create mode 100644 src/backend/app/crud/store_change_request_crud_v2.py create mode 100644 src/backend/test/unit/crud/test_store_change_request_crud_v2.py diff --git a/src/backend/app/crud/store_change_request_crud_v2.py b/src/backend/app/crud/store_change_request_crud_v2.py new file mode 100644 index 0000000..d290e6f --- /dev/null +++ b/src/backend/app/crud/store_change_request_crud_v2.py @@ -0,0 +1,465 @@ +# src/backend/app/crud/store_change_request_crud_v2.py +from typing import Optional, List, Dict, Any, Iterable +from sqlalchemy import Connection, text, exc +from loguru import logger +import json +import datetime + +# 导入枚举类并使用别名 +from backend.app.schemas.store_change_request_schema_v2 import ( + StoreChangeRequestTypeEnum as TypeEnum, # ProductChangeRequestTypeApiEnum + StoreChangeRequestStatusEnum as StatusEnum, # ProductChangeRequestStatusApiEnum +) + +# StoreStatusEnum from store_schema might be needed if ProposedData_JSON for store creation includes it +# from backend.app.schemas.store_schema import StoreStatusEnum + + +class StoreChangeRequestCRUD2: + """ + 店铺变更请求的 CRUD 操作类。 + """ + + __instance: Optional["StoreChangeRequestCRUD2"] = None + + @classmethod + def get_instance(cls) -> "StoreChangeRequestCRUD2": + if cls.__instance is None: + cls.__instance = StoreChangeRequestCRUD2() + return cls.__instance + + def __init__(self): + self.table_name = "StoreChangeRequest" + logger.info(f"{self.__class__.__name__} initialized for table {self.table_name}.") + + @staticmethod + def _set_actor_session_variable(conn: Connection, actor_id: Optional[int]): + try: + if actor_id is not None: + conn.execute(text("SET @actor_id = :actor_id"), {"actor_id": actor_id}) + else: + conn.execute(text("SET @actor_id = NULL")) + except Exception as e: + logger.error(f"Error setting actor session variable: {e}") + + @staticmethod + def _deserialize_proposed_data(data: Optional[Any]) -> Optional[Dict[str, Any]]: + if isinstance(data, (str, bytes, bytearray)): + try: + return json.loads(data) + except json.JSONDecodeError: + logger.warning(f"Failed to decode ProposedData_JSON string: {data}") + return None + return data + + @staticmethod + def _serialize_proposed_data(data: Optional[Dict[str, Any] | str]) -> Optional[str]: + if isinstance(data, dict): + return json.dumps(data) + return data + + def _get_request_base_query(self) -> str: + return """ + SELECT ChangeRequestID, StoreID, RequestingUserID, RequestType, + ProposedData_JSON, Status, SubmitterNotes, AdminReviewerID, + ReviewTimestamp, AdminNotes, CreationTime, LastUpdatedDate + FROM {} + """.format( + self.table_name + ) + + def _process_row(self, row: Optional[Any]) -> Optional[Dict[str, Any]]: + if not row: + return None + row_dict = dict(row._mapping) # type: ignore + row_dict["ProposedData_JSON"] = self._deserialize_proposed_data( + row_dict.get("ProposedData_JSON") + ) + return row_dict + + def _process_rows(self, rows: Iterable[Any]) -> List[Dict[str, Any]]: + return [self._process_row(row) for row in rows if row] # type: ignore + + def get_request_by_id( + self, conn: Connection, *, change_request_id: int + ) -> Optional[Dict[str, Any]]: + logger.debug(f"Getting StoreChangeRequest by ID {change_request_id}") + select_sql = self._get_request_base_query() + " WHERE ChangeRequestID = :ChangeRequestID" + select_stmt = text(select_sql) + try: + result = conn.execute(select_stmt, {"ChangeRequestID": change_request_id}).fetchone() + return self._process_row(result) + except Exception as e: + logger.error(f"Error getting StoreChangeRequest by ID {change_request_id}: {e}") + return None + + def get_request_by_id_for_requesting_user( + self, conn: Connection, *, change_request_id: int, requesting_user_id: int + ) -> Optional[Dict[str, Any]]: + logger.debug( + f"Getting StoreChangeRequest ID {change_request_id} for RequestingUserID {requesting_user_id}" + ) + select_sql = ( + self._get_request_base_query() + + " WHERE ChangeRequestID = :ChangeRequestID AND RequestingUserID = :RequestingUserID" + ) + select_stmt = text(select_sql) + try: + result = conn.execute( + select_stmt, + {"ChangeRequestID": change_request_id, "RequestingUserID": requesting_user_id}, + ).fetchone() + return self._process_row(result) + except Exception as e: + logger.error( + f"Error getting StoreChangeRequest ID {change_request_id} for RequestingUserID {requesting_user_id}: {e}" + ) + return None + + def get_request_list( + self, + conn: Connection, + *, + status_list: Optional[List[str]] = None, + request_type_list: Optional[List[str]] = None, + store_id: Optional[int] = None, + requesting_user_id: Optional[int] = None, + offset: int = 0, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + logger.debug( + f"Getting StoreChangeRequest list with filters - Status: {status_list}, Type: {request_type_list}, " + f"StoreID: {store_id}, RequestingUserID: {requesting_user_id}" + ) + + params: Dict[str, Any] = {} + where_clauses: List[str] = [] + + if status_list: + if isinstance(status_list, list) and len(status_list) > 0: + status_placeholders = [f":status_{i}" for i in range(len(status_list))] + for i, s_val in enumerate(status_list): + params[f"status_{i}"] = s_val + where_clauses.append(f"Status IN ({', '.join(status_placeholders)})") + elif isinstance(status_list, str): + where_clauses.append("Status = :Status_filter_single") + params["Status_filter_single"] = status_list + + if request_type_list: + if isinstance(request_type_list, list) and len(request_type_list) > 0: + type_placeholders = [f":request_type_{i}" for i in range(len(request_type_list))] + for i, rt_val in enumerate(request_type_list): + params[f"request_type_{i}"] = rt_val + where_clauses.append(f"RequestType IN ({', '.join(type_placeholders)})") + elif isinstance(request_type_list, str): + where_clauses.append("RequestType = :RequestType_filter_single") + params["RequestType_filter_single"] = request_type_list + + if store_id is not None: + where_clauses.append("StoreID = :StoreID") + params["StoreID"] = store_id + if requesting_user_id is not None: + where_clauses.append("RequestingUserID = :RequestingUserID") + params["RequestingUserID"] = requesting_user_id + + where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + limit_sql = "" + if limit is not None: + limit_sql = "LIMIT :Limit OFFSET :Offset" + params["Limit"] = limit + params["Offset"] = offset + + select_sql = ( + self._get_request_base_query() + f" {where_sql} ORDER BY CreationTime DESC {limit_sql}" + ) + select_stmt = text(select_sql) + + try: + results = conn.execute(select_stmt, params).fetchall() + return self._process_rows(results) + except Exception as e: + logger.error(f"Error getting StoreChangeRequest list: {e}") + return [] + + def _create_request_internal( + self, + conn: Connection, + *, + requesting_user_id: int, + store_id: Optional[int], + request_type: str, # Expecting TypeEnum.value + proposed_data_json: Optional[Dict[str, Any]], + submitter_notes: Optional[str], + actor_id: Optional[int], + ) -> Optional[Dict[str, Any]]: + self._set_actor_session_variable(conn, actor_id) + + insert_stmt = text( + f""" + INSERT INTO {self.table_name} ( + StoreID, RequestingUserID, RequestType, ProposedData_JSON, SubmitterNotes + ) VALUES ( + :StoreID, :RequestingUserID, :RequestType, :ProposedData_JSON, :SubmitterNotes + ) + """ + ) + serialized_proposed_data = self._serialize_proposed_data(proposed_data_json) + try: + result = conn.execute( + insert_stmt, + { + "StoreID": store_id, + "RequestingUserID": requesting_user_id, + "RequestType": request_type, + "ProposedData_JSON": serialized_proposed_data, + "SubmitterNotes": submitter_notes, + }, + ) + new_request_id = result.lastrowid + if new_request_id is None: + logger.warning( + f"lastrowid not available after creating {request_type} request for RequestingUserID {requesting_user_id}." + ) + return None + logger.info( + f"{request_type} request created with ID {new_request_id} by ActorID {actor_id}." + ) + return self.get_request_by_id(conn, change_request_id=new_request_id) + except exc.IntegrityError as e: + logger.error(f"Integrity error creating {request_type} request: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error creating {request_type} request: {e}") + return None + + def create_request_create_store( + self, + conn: Connection, + *, + requesting_user_id: int, + submitter_notes: Optional[str], + proposed_data_json: Dict[str, Any], + actor_id: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + logger.info( + f"ActorID {actor_id} creating STORE_CREATE request for RequestingUserID {requesting_user_id}" + ) + return self._create_request_internal( + conn=conn, + requesting_user_id=requesting_user_id, + store_id=None, + request_type=TypeEnum.STORE_CREATE.value, # ⭐ Use enum value + proposed_data_json=proposed_data_json, + submitter_notes=submitter_notes, + actor_id=actor_id if actor_id is not None else requesting_user_id, + ) + + def create_request_update_store( + self, + conn: Connection, + *, + store_id: int, + requesting_user_id: int, + submitter_notes: Optional[str], + proposed_data_json: Dict[str, Any], + actor_id: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + logger.info( + f"ActorID {actor_id} creating STORE_UPDATE request for StoreID {store_id}, RequestingUserID {requesting_user_id}" + ) + return self._create_request_internal( + conn=conn, + requesting_user_id=requesting_user_id, + store_id=store_id, + request_type=TypeEnum.STORE_UPDATE.value, # ⭐ Use enum value + proposed_data_json=proposed_data_json, + submitter_notes=submitter_notes, + actor_id=actor_id if actor_id is not None else requesting_user_id, + ) + + def create_request_delete_store( + self, + conn: Connection, + *, + store_id: int, + requesting_user_id: int, + submitter_notes: Optional[str], + actor_id: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + logger.info( + f"ActorID {actor_id} creating STORE_DELETE request for StoreID {store_id}, RequestingUserID {requesting_user_id}" + ) + return self._create_request_internal( + conn=conn, + requesting_user_id=requesting_user_id, + store_id=store_id, + request_type=TypeEnum.STORE_DELETE.value, # ⭐ Use enum value + proposed_data_json=None, + submitter_notes=submitter_notes, + actor_id=actor_id if actor_id is not None else requesting_user_id, + ) + + def cancel_request_by_user( + self, + conn: Connection, + *, + change_request_id: int, + actor_id: Optional[int] = None, + ) -> bool: + logger.info( + f"ActorID {actor_id} attempting to cancel StoreChangeRequestID {change_request_id}." + ) + self._set_actor_session_variable(conn, actor_id) + + update_stmt = text( + f""" + UPDATE {self.table_name} + SET Status = :cancelled_status + WHERE ChangeRequestID = :ChangeRequestID AND Status = :pending_status + """ + ) + try: + result = conn.execute( + update_stmt, + { + "cancelled_status": StatusEnum.CANCELLED_BY_USER.value, # ⭐ Use enum value + "ChangeRequestID": change_request_id, + "pending_status": StatusEnum.PENDING_APPROVAL.value, # ⭐ Use enum value + }, + ) + if result.rowcount > 0: + logger.info( + f"StoreChangeRequestID {change_request_id} status updated to {StatusEnum.CANCELLED_BY_USER.value} by ActorID {actor_id}." + ) + return True + else: + logger.warning( + f"Failed to cancel StoreChangeRequestID {change_request_id} by ActorID {actor_id}. " + f"Request not found or not in PENDING_APPROVAL status." + ) + return False + except Exception as e: + logger.error( + f"Error cancelling StoreChangeRequestID {change_request_id} by ActorID {actor_id}: {e}" + ) + return False + + def update_request_by_admin( + self, + conn: Connection, + *, + change_request_id: int, + status: str, # Expecting StatusEnum.value + admin_notes: Optional[str], + admin_reviewer_id: int, + actor_id: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + logger.info( + f"AdminID {admin_reviewer_id} (ActorID {actor_id}) updating StoreChangeRequestID {change_request_id} to Status {status}." + ) + self._set_actor_session_variable( + conn, actor_id if actor_id is not None else admin_reviewer_id + ) + + set_clauses: List[str] = [ + "Status = :Status", + "AdminReviewerID = :AdminReviewerID", + "ReviewTimestamp = UTC_TIMESTAMP()", + ] + params: Dict[str, Any] = { + "ChangeRequestID_param": change_request_id, + "Status": status, # Already a string value + "AdminReviewerID": admin_reviewer_id, + } + + if admin_notes is not None: + set_clauses.append("AdminNotes = :AdminNotes") + params["AdminNotes"] = admin_notes + + update_stmt_str = ( + f"UPDATE {self.table_name}" + f" SET {', '.join(set_clauses)}" + f" WHERE ChangeRequestID = :ChangeRequestID_param" + ) + try: + result = conn.execute(text(update_stmt_str), params) + if result.rowcount == 0: + logger.warning( + f"StoreChangeRequestID {change_request_id} not found for admin update, or no effective change." + ) + return self.get_request_by_id(conn, change_request_id=change_request_id) + + logger.info( + f"StoreChangeRequestID {change_request_id} updated by AdminID {admin_reviewer_id} to Status {status}." + ) + return self.get_request_by_id(conn, change_request_id=change_request_id) + except Exception as e: + logger.error(f"Error updating StoreChangeRequestID {change_request_id} by admin: {e}") + return None + + def update_request_store_id_and_status_applied( + self, + conn: Connection, + *, + change_request_id: int, + applied_store_id: Optional[int], + actor_id: Optional[int], + ) -> Optional[Dict[str, Any]]: + """ + 在 STORE_CREATE 或 STORE_UPDATE 请求被批准并应用后, + 更新请求状态为 APPLIED,并(如果是 STORE_CREATE)回填 StoreID 字段。 + + :param conn: 数据库连接。 + :param change_request_id: 已应用的变更请求ID。 + :param applied_store_id: (可选) 对于 STORE_CREATE,这是新创建的店铺ID。 + 对于 STORE_UPDATE,这应该是已存在的店铺ID (与请求中的StoreID一致)。 + 如果为 None (例如,在应用 STORE_DELETE 之后),则不更新 StoreID 列。 + :param actor_id: (可选) 执行此操作的ID (通常是系统或管理员)。 + :return: 更新成功后的请求信息字典,如果失败则返回 None。 + """ + logger.info( + f"ActorID {actor_id} marking ChangeRequestID {change_request_id} as APPLIED. Applied StoreID: {applied_store_id}" + ) + self._set_actor_session_variable(conn, actor_id) + + set_clauses: List[str] = ["Status = :AppliedStatus"] + params: Dict[str, Any] = { + "ChangeRequestID_param": change_request_id, + "AppliedStatus": StatusEnum.APPLIED.value, # ⭐ Use enum value + "ApprovedStatus": StatusEnum.APPROVED.value, # ⭐ Use enum value + } + + # 只有在 applied_store_id 提供时才尝试更新 StoreID 列 + if applied_store_id is not None: + set_clauses.append("StoreID = :AppliedStoreID") + params["AppliedStoreID"] = applied_store_id + + # 确保只更新状态为 APPROVED 的请求 + update_stmt_str = ( + f"UPDATE {self.table_name}" + f" SET {', '.join(set_clauses)}" + f" WHERE ChangeRequestID = :ChangeRequestID_param AND Status = :ApprovedStatus" + ) + + try: + result = conn.execute(text(update_stmt_str), params) + if result.rowcount == 0: + logger.warning( + f"Failed to mark ChangeRequestID {change_request_id} as APPLIED. " + f"Request not found, not in APPROVED status, or StoreID already set if it was part of update." + ) + # 仍然尝试获取当前状态,因为可能是并发更新或已经是APPLIED + return self.get_request_by_id(conn, change_request_id=change_request_id) + + logger.info( + f"ChangeRequestID {change_request_id} marked as APPLIED. Applied StoreID (if any): {applied_store_id}." + ) + return self.get_request_by_id(conn, change_request_id=change_request_id) + except Exception as e: + logger.error(f"Error marking ChangeRequestID {change_request_id} as APPLIED: {e}") + return None + + +# 单例实例 +store_change_request_crud_instance2 = StoreChangeRequestCRUD2.get_instance() diff --git a/src/backend/test/unit/crud/test_store_change_request_crud_v2.py b/src/backend/test/unit/crud/test_store_change_request_crud_v2.py new file mode 100644 index 0000000..31b45fd --- /dev/null +++ b/src/backend/test/unit/crud/test_store_change_request_crud_v2.py @@ -0,0 +1,364 @@ +# src/backend/test/unit/crud/test_store_change_request_crud_v2.py +import unittest +from unittest.mock import MagicMock, patch, ANY, call +import datetime +import json +from typing import Optional, Dict, Any, List + +# 假设 StoreChangeRequestCRUD2 和相关枚举位于以下路径 +from backend.app.crud.store_change_request_crud_v2 import StoreChangeRequestCRUD2 + +# ⭐ 修正: 从 store_change_request_schema_v2 导入正确的枚举 +from backend.app.schemas.store_change_request_schema_v2 import ( + StoreChangeRequestTypeEnum as TypeEnum, # 使用您 DDL 中 StoreChangeRequest 表的 RequestType 枚举 + StoreChangeRequestStatusEnum as StatusEnum, # 使用您 DDL 中 StoreChangeRequest 表的 Status 枚举 +) + +# 用于 SQLAlchemy text 和 exc (如果需要模拟异常) +from sqlalchemy import text, exc +from sqlalchemy.engine.base import Connection # 用于类型提示 + + +# 辅助函数来规范化SQL字符串以便比较 +def normalize_sql(sql_string: str) -> str: + """将SQL字符串中的多个空格和换行符替换为单个空格,并去除首尾空格。""" + return " ".join(sql_string.strip().split()) + + +class TestStoreChangeRequestCRUD2(unittest.TestCase): + + def setUp(self): + self.crud = StoreChangeRequestCRUD2.get_instance() + self.mock_conn = MagicMock(spec=Connection) + + self.mock_cursor_result = MagicMock() + self.mock_conn.execute.return_value = self.mock_cursor_result + + self.set_actor_patcher = patch.object( + StoreChangeRequestCRUD2, "_set_actor_session_variable" + ) + self.mock_set_actor_session_variable = self.set_actor_patcher.start() + self.addCleanup(self.set_actor_patcher.stop) + + # ⭐ 实现 self.sample_request_data 的创建 + self.sample_request_data = { + "ChangeRequestID": 1, + "StoreID": 10, + "RequestingUserID": 101, + "RequestType": TypeEnum.STORE_UPDATE.value, # 使用 StoreChangeRequestTypeApiEnum + "ProposedData_JSON": {"StoreName": "Updated Store Name From Sample"}, # 已经是字典 + "Status": StatusEnum.PENDING_APPROVAL.value, # 使用 StoreChangeRequestStatusApiEnum + "SubmitterNotes": "Sample review store update", + "AdminReviewerID": None, + "ReviewTimestamp": None, + "AdminNotes": None, + "CreationTime": datetime.datetime(2025, 1, 1, 10, 0, 0), + "LastUpdatedDate": datetime.datetime(2025, 1, 1, 10, 5, 0), + } + + # --- 测试 _deserialize_proposed_data 和 _serialize_proposed_data --- + def test_deserialize_proposed_data_store(self): + json_str = '{"StoreName": "My Store", "Description": "Great"}' + expected_dict = {"StoreName": "My Store", "Description": "Great"} + self.assertEqual(self.crud._deserialize_proposed_data(json_str), expected_dict) + self.assertEqual(self.crud._deserialize_proposed_data(expected_dict), expected_dict) + self.assertIsNone(self.crud._deserialize_proposed_data(None)) + with patch("backend.app.crud.store_change_request_crud_v2.logger") as mock_logger: + self.assertIsNone(self.crud._deserialize_proposed_data("invalid json string")) + mock_logger.warning.assert_called_once() + + def test_serialize_proposed_data_store(self): + data_dict = {"StoreName": "My Store"} + expected_json_str = json.dumps(data_dict) + self.assertEqual(self.crud._serialize_proposed_data(data_dict), expected_json_str) + # 如果传入的已经是字符串,应该原样返回(根据当前_serialize_proposed_data实现) + self.assertEqual(self.crud._serialize_proposed_data(expected_json_str), expected_json_str) + self.assertIsNone(self.crud._serialize_proposed_data(None)) + + # --- 测试 get_request_by_id --- + def test_get_request_by_id_found_store(self): + request_id = 1 + mock_row = MagicMock() + # 模拟数据库返回的是 JSON 字符串 + db_return_data = { + **self.sample_request_data, # 使用 setUp 中定义的 + "ProposedData_JSON": '{"StoreName": "Updated Store Name From DB"}', + } + mock_row._mapping = db_return_data + self.mock_cursor_result.fetchone.return_value = mock_row + + request = self.crud.get_request_by_id(self.mock_conn, change_request_id=request_id) + + self.mock_conn.execute.assert_called_once_with(ANY, {"ChangeRequestID": request_id}) + self.assertIsNotNone(request) + self.assertEqual(request["ChangeRequestID"], request_id) # type: ignore + # _deserialize_proposed_data 应该将其转换为字典 + self.assertEqual(request["ProposedData_JSON"], {"StoreName": "Updated Store Name From DB"}) # type: ignore + + # --- 测试 get_request_by_id_for_requesting_user --- + def test_get_request_by_id_for_requesting_user_found(self): + request_id = self.sample_request_data["ChangeRequestID"] + requesting_user_id = self.sample_request_data["RequestingUserID"] + mock_row = MagicMock() + db_return_data = {**self.sample_request_data, "ProposedData_JSON": '{"key": "val"}'} + mock_row._mapping = db_return_data + self.mock_cursor_result.fetchone.return_value = mock_row + + request = self.crud.get_request_by_id_for_requesting_user( + self.mock_conn, change_request_id=request_id, requesting_user_id=requesting_user_id + ) + self.mock_conn.execute.assert_called_once_with( + ANY, {"ChangeRequestID": request_id, "RequestingUserID": requesting_user_id} + ) + self.assertIsNotNone(request) + self.assertEqual(request["RequestingUserID"], requesting_user_id) # type: ignore + + # --- 测试 get_request_list --- + def test_get_request_list_with_status_and_type_filter(self): + status_filter = [StatusEnum.PENDING_APPROVAL.value] + type_filter = TypeEnum.STORE_CREATE.value + + mock_row1_db = { + **self.sample_request_data, + "ChangeRequestID": 1, + "Status": status_filter[0], + "RequestType": type_filter, + "ProposedData_JSON": '{"s":1}', + } + row1 = MagicMock() + row1._mapping = mock_row1_db + self.mock_cursor_result.fetchall.return_value = [row1] + + requests = self.crud.get_request_list( + self.mock_conn, status_list=status_filter, request_type_list=type_filter + ) + + self.mock_conn.execute.assert_called_once() + call_args = self.mock_conn.execute.call_args.args + normalized_sql = normalize_sql(str(call_args[0].text)) + params = call_args[1] + + self.assertIn("Status IN (:status_0)", normalized_sql) + self.assertEqual(params["status_0"], status_filter[0]) + self.assertIn("RequestType = :RequestType_filter_single", normalized_sql) + self.assertEqual(params["RequestType_filter_single"], type_filter) + + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["ProposedData_JSON"], {"s": 1}) + + # --- 测试 create_request_create_store --- + def test_create_request_create_store_success(self): + requesting_user_id = 101 + actor_id = requesting_user_id + proposed_data = {"StoreName": "My New Awesome Store", "Description": "The best."} + submitter_notes = "Initial store creation request" + expected_request_id = 2001 + + self.mock_cursor_result.lastrowid = expected_request_id + + mock_final_request_data = { + "ChangeRequestID": expected_request_id, + "RequestingUserID": requesting_user_id, + "StoreID": None, # StoreID 为 None 对于创建店铺请求 + "RequestType": TypeEnum.STORE_CREATE.value, + "ProposedData_JSON": proposed_data, + "SubmitterNotes": submitter_notes, + "Status": StatusEnum.PENDING_APPROVAL.value, # 数据库默认值 + # ... 其他时间戳字段由数据库默认或 get_request_by_id 填充 + "CreationTime": datetime.datetime.now(datetime.UTC), + "LastUpdatedDate": datetime.datetime.now(datetime.UTC), + } + with patch.object( + self.crud, "get_request_by_id", return_value=mock_final_request_data + ) as mock_get_by_id: + created_request = self.crud.create_request_create_store( + conn=self.mock_conn, + requesting_user_id=requesting_user_id, + submitter_notes=submitter_notes, + proposed_data_json=proposed_data, + actor_id=actor_id, + ) + self.mock_set_actor_session_variable.assert_called_with(self.mock_conn, actor_id) + self.mock_conn.execute.assert_called_once() + + call_args = self.mock_conn.execute.call_args.args + params = call_args[1] + self.assertEqual(params["RequestType"], TypeEnum.STORE_CREATE.value) + self.assertEqual(params["ProposedData_JSON"], json.dumps(proposed_data)) + self.assertIsNone(params["StoreID"]) + + mock_get_by_id.assert_called_once_with( + self.mock_conn, change_request_id=expected_request_id + ) + self.assertEqual(created_request, mock_final_request_data) + + # --- 测试 create_request_update_store --- + def test_create_request_update_store_success(self): + requesting_user_id = 101 + store_id_to_update = self.sample_request_data["StoreID"] + actor_id = requesting_user_id + proposed_data = {"Description": "Updated description."} + submitter_notes = "Update request for store description" + expected_request_id = 2002 + + self.mock_cursor_result.lastrowid = expected_request_id + mock_final_request_data = { + "ChangeRequestID": expected_request_id, + "RequestingUserID": requesting_user_id, + "StoreID": store_id_to_update, + "RequestType": TypeEnum.STORE_UPDATE.value, + "ProposedData_JSON": proposed_data, + "SubmitterNotes": submitter_notes, + "Status": StatusEnum.PENDING_APPROVAL.value, + "CreationTime": datetime.datetime.now(datetime.UTC), + "LastUpdatedDate": datetime.datetime.now(datetime.UTC), + } + with patch.object( + self.crud, "get_request_by_id", return_value=mock_final_request_data + ) as mock_get_by_id: + created_request = self.crud.create_request_update_store( + conn=self.mock_conn, + requesting_user_id=requesting_user_id, + store_id=store_id_to_update, + submitter_notes=submitter_notes, + proposed_data_json=proposed_data, + actor_id=actor_id, + ) + self.mock_conn.execute.assert_called_once() + params = self.mock_conn.execute.call_args.args[1] + self.assertEqual(params["RequestType"], TypeEnum.STORE_UPDATE.value) + self.assertEqual(params["StoreID"], store_id_to_update) + self.assertEqual(created_request, mock_final_request_data) + + # --- 测试 cancel_request_by_user --- + def test_cancel_request_by_user_success(self): + request_id = 1 + actor_id = 201 + self.mock_cursor_result.rowcount = 1 + + result = self.crud.cancel_request_by_user( + self.mock_conn, change_request_id=request_id, actor_id=actor_id + ) + + self.assertTrue(result) + self.mock_set_actor_session_variable.assert_called_once_with(self.mock_conn, actor_id) + self.mock_conn.execute.assert_called_once() + + call_args = self.mock_conn.execute.call_args.args + params = call_args[1] + self.assertEqual(params["cancelled_status"], StatusEnum.CANCELLED_BY_USER.value) + self.assertEqual(params["pending_status"], StatusEnum.PENDING_APPROVAL.value) + self.assertEqual(params["ChangeRequestID"], request_id) + + # --- 测试 update_request_by_admin --- + def test_update_request_by_admin_approve_success(self): + request_id = 1 + admin_reviewer_id = 999 + actor_id = admin_reviewer_id + new_status_enum = StatusEnum.APPROVED # Pass enum member + admin_notes = "Store approved." + + self.mock_cursor_result.rowcount = 1 + + # 模拟 get_request_by_id 返回的数据,确保所有字段都存在 + mock_updated_request_data = { + **self.sample_request_data, # Use a base with all fields + "ChangeRequestID": request_id, + "Status": new_status_enum.value, # Stored as string value + "AdminReviewerID": admin_reviewer_id, + "AdminNotes": admin_notes, + "ReviewTimestamp": datetime.datetime.now(datetime.UTC), # Simulate DB update + "LastUpdatedDate": datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=1), + } + with patch.object( + self.crud, "get_request_by_id", return_value=mock_updated_request_data + ) as mock_get_by_id: + updated_request = self.crud.update_request_by_admin( + conn=self.mock_conn, + change_request_id=request_id, + status=new_status_enum.value, # Pass string value to SUT + admin_notes=admin_notes, + admin_reviewer_id=admin_reviewer_id, + actor_id=actor_id, + ) + + self.mock_set_actor_session_variable.assert_called_with(self.mock_conn, actor_id) + self.mock_conn.execute.assert_called_once() + + call_args = self.mock_conn.execute.call_args.args + params = call_args[1] + self.assertEqual(params["Status"], new_status_enum.value) + self.assertEqual(params["AdminReviewerID"], admin_reviewer_id) + + mock_get_by_id.assert_called_once_with(self.mock_conn, change_request_id=request_id) + self.assertEqual(updated_request, mock_updated_request_data) + + # --- 测试 update_request_store_id_and_status_applied --- + def test_update_request_store_id_and_status_applied_for_create(self): + change_request_id = 1 + applied_store_id = 501 + actor_id = 999 + + self.mock_cursor_result.rowcount = 1 + mock_final_request_data = { + **self.sample_request_data, + "ChangeRequestID": change_request_id, + "StoreID": applied_store_id, + "Status": StatusEnum.APPLIED.value, + } + with patch.object( + self.crud, "get_request_by_id", return_value=mock_final_request_data + ) as mock_get_by_id: + updated_request = self.crud.update_request_store_id_and_status_applied( + conn=self.mock_conn, + change_request_id=change_request_id, + applied_store_id=applied_store_id, + actor_id=actor_id, + ) + self.mock_set_actor_session_variable.assert_called_with(self.mock_conn, actor_id) + self.mock_conn.execute.assert_called_once() + + call_args = self.mock_conn.execute.call_args.args + params = call_args[1] + self.assertEqual(params["AppliedStatus"], StatusEnum.APPLIED.value) + self.assertEqual(params["ApprovedStatus"], StatusEnum.APPROVED.value) + self.assertEqual(params["AppliedStoreID"], applied_store_id) + + self.assertEqual(updated_request, mock_final_request_data) + + def test_update_request_store_id_and_status_applied_for_update_no_store_id_change(self): + change_request_id = 2 + actor_id = 999 + + self.mock_cursor_result.rowcount = 1 + mock_final_request_data = { + **self.sample_request_data, + "ChangeRequestID": change_request_id, + "Status": StatusEnum.APPLIED.value, + # StoreID remains as in self.sample_request_data as applied_store_id is None + } + with patch.object( + self.crud, "get_request_by_id", return_value=mock_final_request_data + ) as mock_get_by_id: + updated_request = self.crud.update_request_store_id_and_status_applied( + conn=self.mock_conn, + change_request_id=change_request_id, + applied_store_id=None, # Not changing StoreID + actor_id=actor_id, + ) + self.mock_conn.execute.assert_called_once() + call_args = self.mock_conn.execute.call_args.args + normalized_sql = normalize_sql(str(call_args[0].text)) + params = call_args[1] + + self.assertIn( + f"UPDATE {self.crud.table_name} SET Status = :AppliedStatus", normalized_sql + ) + self.assertNotIn("StoreID = :AppliedStoreID", normalized_sql) + self.assertEqual(params["AppliedStatus"], StatusEnum.APPLIED.value) + self.assertNotIn("AppliedStoreID", params) + self.assertEqual(updated_request, mock_final_request_data) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From aaaf91cc31403bb43494c38144f7f85ee34d5348 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sat, 24 May 2025 23:34:27 +0800 Subject: [PATCH 22/26] feat(store_change_request_service_v2): implement store change request service --- .../product_change_request_service_v2.py | 3 + .../store_change_request_service_v2.py | 568 ++++++++++++++++++ .../test_store_change_request_service_v2.py | 400 ++++++++++++ 3 files changed, 971 insertions(+) create mode 100644 src/backend/app/services/store_change_request_service_v2.py create mode 100644 src/backend/test/unit/service/test_store_change_request_service_v2.py diff --git a/src/backend/app/services/product_change_request_service_v2.py b/src/backend/app/services/product_change_request_service_v2.py index 63ec322..2abfbb8 100644 --- a/src/backend/app/services/product_change_request_service_v2.py +++ b/src/backend/app/services/product_change_request_service_v2.py @@ -644,4 +644,7 @@ async def apply_approved_request( logger.success( f"ChangeRequestID {change_request_id} successfully applied by UserID {applier_user.UserID}." ) + logger.debug( + f"Final ProductChangeRequest data: {final_pcr_dict}" + ) return ProductChangeRequestResponse(**final_pcr_dict) diff --git a/src/backend/app/services/store_change_request_service_v2.py b/src/backend/app/services/store_change_request_service_v2.py new file mode 100644 index 0000000..fc3809c --- /dev/null +++ b/src/backend/app/services/store_change_request_service_v2.py @@ -0,0 +1,568 @@ +# src/backend/app/services/store_change_request_service_v2.py +from sqlalchemy.engine.base import Connection +from typing import Optional, List, Dict, Any, Tuple +from decimal import Decimal +import datetime +import json # For parsing ProposedData_JSON + +from loguru import logger + +# 导入相关的 CRUD 类 +from backend.app.crud.store_change_request_crud_v2 import StoreChangeRequestCRUD2 +from backend.app.crud.store_crud import StoreCRUD +from backend.app.crud.user_crud import UserCRUD + +# 导入相关的 Pydantic Schemas +from backend.app.schemas.store_change_request_schema_v2 import ( + StoreChangeRequestCreate as SCR_CreateRequest, # Renamed to avoid clash with CRUD method + StoreChangeRequestResponse, + StoreChangeRequestListResponse, + StoreChangeRequestUpdateRequestByAdmin as SCR_UpdateByAdminRequest, + StoreChangeRequestQueryParams, + StoreChangeRequestTypeEnum as RequestTypeEnum, + StoreChangeRequestStatusEnum as StatusEnum, + ProposedStoreData, # Assuming this schema exists for ProposedData_JSON content +) +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.store_schema import StoreStatusEnum # For setting store status + +# 导入自定义异常 +from backend.app.utils.exceptions import ( + StoreNotFoundException, + # ProductNotFoundException, # Not directly used here, but Store can be not found + PermissionDeniedException, + InvalidOperationException, + BadRequestException, + UserNotFoundException, +) + + +class StoreChangeRequestService2: + """ + 店铺变更请求的服务类,处理业务逻辑。 + """ + + def __init__( + self, + scr_crud: StoreChangeRequestCRUD2, # scr_crud for StoreChangeRequestCRUD + store_crud: StoreCRUD, + user_crud: UserCRUD, + ): + self._scr_crud = scr_crud + self._store_crud = store_crud + self._user_crud = user_crud + logger.info(f"{self.__class__.__name__} initialized.") + + async def submit_new_request( + self, + db: Connection, + *, + requesting_user: CurrentUserSchema, + request_in: SCR_CreateRequest, # Using SCR_CreateRequest + ) -> StoreChangeRequestResponse: + logger.info( + f"RequestingUserID {requesting_user.UserID} submitting new store change request. Type: {request_in.RequestType.value}, Target StoreID (if any): {request_in.StoreID}" + ) + + # 1. 基础校验 + if request_in.RequestType == RequestTypeEnum.STORE_CREATE: + if request_in.StoreID is not None: # StoreID should be None for create requests + raise BadRequestException( + "StoreID must be null or not provided for STORE_CREATE requests." + ) + if not request_in.ProposedData_JSON: + raise BadRequestException( + "ProposedData_JSON is required for STORE_CREATE requests." + ) + # 进一步校验 ProposedData_JSON 内容 (例如,必须包含 StoreName) + try: + proposed_data = ProposedStoreData(**request_in.ProposedData_JSON.model_dump(exclude_unset=True)) # type: ignore + if not proposed_data.StoreName: + raise BadRequestException( + "StoreName is required in ProposedData_JSON for STORE_CREATE." + ) + except Exception as e: # Pydantic ValidationError or other + raise BadRequestException(f"Invalid ProposedData_JSON for STORE_CREATE: {e}") + + elif request_in.RequestType == RequestTypeEnum.STORE_UPDATE: + if request_in.StoreID is None: + raise BadRequestException("StoreID is required for STORE_UPDATE requests.") + if not request_in.ProposedData_JSON: + raise BadRequestException( + "ProposedData_JSON is required for STORE_UPDATE requests." + ) + if not any(request_in.ProposedData_JSON.model_dump(exclude_unset=True).values()): + raise BadRequestException( + "ProposedData_JSON must contain at least one field to update for STORE_UPDATE." + ) + # 验证 StoreID 存在且属于 requesting_user (除非是管理员代为提交,但这里是商家提交) + store_to_update = self._store_crud.get_store_by_id(conn=db, store_id=request_in.StoreID) + if not store_to_update: + raise StoreNotFoundException( + f"Target StoreID {request_in.StoreID} not found for update request." + ) + if store_to_update["OwnerUserID"] != requesting_user.UserID: + logger.warning( + f"User {requesting_user.UserID} attempting to submit UPDATE request for store {request_in.StoreID} not owned by them." + ) + # TODO: 使用专门的权限管理类 + + elif request_in.RequestType == RequestTypeEnum.STORE_DELETE: + if request_in.StoreID is None: + raise BadRequestException("StoreID is required for STORE_DELETE requests.") + if request_in.ProposedData_JSON is not None: + raise BadRequestException( + "ProposedData_JSON must be null or not provided for STORE_DELETE requests." + ) + # 验证 StoreID 存在且属于 requesting_user + store_to_delete = self._store_crud.get_store_by_id(conn=db, store_id=request_in.StoreID) + if not store_to_delete: + raise StoreNotFoundException( + f"Target StoreID {request_in.StoreID} not found for delete request." + ) + if store_to_delete["OwnerUserID"] != requesting_user.UserID: + logger.warning( + f"User {requesting_user.UserID} attempting to submit DELETE request for store {request_in.StoreID} not owned by them." + ) + # TODO: 使用专门的权限管理类 + + # 2. 调用 CRUD 创建请求记录 + # CRUD 层期望 ProposedData_JSON 是字典 + proposed_data_as_dict = ( + request_in.ProposedData_JSON.model_dump(exclude_unset=True) + if request_in.ProposedData_JSON + else None + ) + + created_request_dict: Optional[Dict[str, Any]] = None + if request_in.RequestType == RequestTypeEnum.STORE_CREATE: + created_request_dict = self._scr_crud.create_request_create_store( + conn=db, + requesting_user_id=requesting_user.UserID, + submitter_notes=request_in.SubmitterNotes, + proposed_data_json=proposed_data_as_dict, # type: ignore + actor_id=requesting_user.UserID, + ) + elif request_in.RequestType == RequestTypeEnum.STORE_UPDATE: + created_request_dict = self._scr_crud.create_request_update_store( + conn=db, + store_id=request_in.StoreID, # type: ignore + requesting_user_id=requesting_user.UserID, + submitter_notes=request_in.SubmitterNotes, + proposed_data_json=proposed_data_as_dict, # type: ignore + actor_id=requesting_user.UserID, + ) + elif request_in.RequestType == RequestTypeEnum.STORE_DELETE: + created_request_dict = self._scr_crud.create_request_delete_store( + conn=db, + store_id=request_in.StoreID, # type: ignore + requesting_user_id=requesting_user.UserID, + submitter_notes=request_in.SubmitterNotes, # ProposedData_JSON is None for delete + actor_id=requesting_user.UserID, + ) + + if not created_request_dict: + logger.error( + f"Failed to create store change request in CRUD for User {requesting_user.UserID}" + ) + raise Exception("Failed to submit store change request.") + + logger.success( + f"StoreChangeRequest ID {created_request_dict['ChangeRequestID']} submitted by User {requesting_user.UserID}." + ) + return StoreChangeRequestResponse(**created_request_dict) + + async def get_request_details( + self, + db: Connection, + *, + change_request_id: int, + actor_user: CurrentUserSchema, + ) -> StoreChangeRequestResponse: + logger.info( + f"ActorID {actor_user.UserID} attempting to get details for StoreChangeRequestID {change_request_id}" + ) + + request_data = self._scr_crud.get_request_by_id( + conn=db, change_request_id=change_request_id + ) + if not request_data: + raise StoreNotFoundException( + f"StoreChangeRequest with ID {change_request_id} not found." + ) # Using StoreNotFound as a generic "resource not found" + + # 权限检查: 商家只能看自己的,管理员可以看所有 + if ( + request_data["RequestingUserID"] != actor_user.UserID + ): + logger.warning( + f"Permission Denied: ActorID {actor_user.UserID} cannot view SCR ID {change_request_id} by RequesterID {request_data['RequestingUserID']}." + ) + # TODO: 使用专门的权限管理类 + + return StoreChangeRequestResponse(**request_data) + + async def list_requests_for_requesting_user( # Renamed from list_requests_for_merchant + self, + db: Connection, + *, + requesting_user: CurrentUserSchema, # 当前用户 + query_params: StoreChangeRequestQueryParams, + ) -> StoreChangeRequestListResponse: + logger.info( + f"RequestingUserID {requesting_user.UserID} listing their store change requests with filters: {query_params.model_dump(exclude_none=True)}" + ) + + status_list_values: Optional[List[str]] = ( + [s.value for s in query_params.Status] if query_params.Status else None + ) + request_type_value: Optional[str] = ( + query_params.RequestType.value if query_params.RequestType else None + ) + + requests_data = self._scr_crud.get_request_list( + conn=db, + requesting_user_id=requesting_user.UserID, # 强制用户ID + status_list=status_list_values, + request_type_list=request_type_value, # CRUD expects list or str, schema provides single enum + store_id=query_params.StoreID, + # ProductID is not a filter for StoreChangeRequest list by user in this context + ) + + response_items = [StoreChangeRequestResponse(**data) for data in requests_data] + return StoreChangeRequestListResponse( + Requests=response_items, TotalCount=len(response_items) + ) + + async def list_requests_for_admin( + self, + db: Connection, + *, + admin_user: CurrentUserSchema, # 当前管理员用户 + query_params: StoreChangeRequestQueryParams, + ) -> StoreChangeRequestListResponse: + logger.info( + f"Admin UserID {admin_user.UserID} listing all store change requests with filters: {query_params.model_dump(exclude_none=True)}" + ) + # if not _is_actor_admin(admin_user): # Explicit admin check + # raise PermissionDeniedException("Only administrators can list all requests.") + # TODO: 使用专门的权限管理类 + + status_list_values: Optional[List[str]] = ( + [s.value for s in query_params.Status] if query_params.Status else None + ) + request_type_value: Optional[str] = ( + query_params.RequestType.value if query_params.RequestType else None + ) + + requests_data = self._scr_crud.get_request_list( + conn=db, + status_list=status_list_values, + request_type_list=request_type_value, + store_id=query_params.StoreID, + requesting_user_id=query_params.MerchantUserID, # Admin can filter by user + # ProductID filter is not in StoreChangeRequest DDL directly, but StoreID is. + ) + response_items = [StoreChangeRequestResponse(**data) for data in requests_data] + return StoreChangeRequestListResponse( + Requests=response_items, TotalCount=len(response_items) + ) + + async def user_cancel_request( # Renamed from merchant_cancel_request + self, + db: Connection, + *, + change_request_id: int, + requesting_user: CurrentUserSchema, # 执行操作的用户 + ) -> StoreChangeRequestResponse: + logger.info( + f"RequestingUserID {requesting_user.UserID} attempting to cancel StoreChangeRequestID {change_request_id}" + ) + + request_to_cancel = self._scr_crud.get_request_by_id( + conn=db, change_request_id=change_request_id + ) + if not request_to_cancel: + raise StoreNotFoundException( + f"StoreChangeRequest with ID {change_request_id} not found." + ) + + if request_to_cancel["RequestingUserID"] != requesting_user.UserID: + logger.warning( + f"Permission Denied: User {requesting_user.UserID} cannot cancel request {change_request_id} by {request_to_cancel['RequestingUserID']}." + ) + # TODO: 使用专门的权限管理类 + + if request_to_cancel["Status"] != StatusEnum.PENDING_APPROVAL.value: + raise InvalidOperationException( + f"Request {change_request_id} is not in PENDING_APPROVAL status, cannot be cancelled." + ) + + success = self._scr_crud.cancel_request_by_user( # CRUD method name updated + conn=db, change_request_id=change_request_id, actor_id=requesting_user.UserID + ) + if not success: + raise Exception("Failed to cancel the request in CRUD layer.") + + updated_request_dict = self._scr_crud.get_request_by_id( + conn=db, change_request_id=change_request_id + ) + if not updated_request_dict: + raise StoreNotFoundException( + f"Request {change_request_id} not found after cancellation attempt." + ) + + logger.success( + f"StoreChangeRequestID {change_request_id} cancelled by User {requesting_user.UserID}." + ) + return StoreChangeRequestResponse(**updated_request_dict) + + async def admin_review_request( + self, + db: Connection, + *, + change_request_id: int, + admin_user: CurrentUserSchema, + review_data: SCR_UpdateByAdminRequest, # Using SCR_UpdateByAdminRequest + ) -> StoreChangeRequestResponse: + logger.info( + f"Admin UserID {admin_user.UserID} reviewing StoreChangeRequestID {change_request_id} with Status {review_data.Status.value}" + ) + # if not _is_actor_admin(admin_user): + # raise PermissionDeniedException("Only administrators can review requests.") + # TODO: 使用专门的权限管理类 + + request_to_review = self._scr_crud.get_request_by_id( + conn=db, change_request_id=change_request_id + ) + if not request_to_review: + raise StoreNotFoundException( + f"StoreChangeRequest with ID {change_request_id} not found for review." + ) + + if request_to_review["Status"] != StatusEnum.PENDING_APPROVAL.value: + raise InvalidOperationException( + f"Request {change_request_id} is not in PENDING_APPROVAL status, cannot be reviewed." + ) + + updated_request_dict = self._scr_crud.update_request_by_admin( + conn=db, + change_request_id=change_request_id, + status=review_data.Status.value, + admin_notes=review_data.AdminNotes, + admin_reviewer_id=admin_user.UserID, + actor_id=admin_user.UserID, + ) + if not updated_request_dict: + raise Exception("Failed to review and update request status in CRUD layer.") + + logger.success( + f"StoreChangeRequestID {change_request_id} reviewed by Admin {admin_user.UserID}, new status: {review_data.Status.value}." + ) + + if review_data.Status == StatusEnum.APPROVED: + logger.info( + f"Request {change_request_id} approved by admin. Attempting to apply changes." + ) + try: + # apply_approved_request is now public + return await self.apply_approved_request( + db=db, change_request_id=change_request_id, applier_user=admin_user + ) + except Exception as e: + logger.error( + f"Failed to apply approved request {change_request_id} by Admin {admin_user.UserID}: {e}" + ) + # Return the request in its 'APPROVED' state if application fails + + return StoreChangeRequestResponse(**updated_request_dict) + + async def apply_approved_request( + self, + db: Connection, + *, + change_request_id: int, + applier_user: CurrentUserSchema, # User performing the application (admin or system) + ) -> StoreChangeRequestResponse: + logger.info( + f"UserID {applier_user.UserID} attempting to apply approved StoreChangeRequestID {change_request_id}" + ) + + pcr_data = self._scr_crud.get_request_by_id(conn=db, change_request_id=change_request_id) + if not pcr_data: + raise StoreNotFoundException( + f"StoreChangeRequest with ID {change_request_id} not found for application." + ) + if pcr_data["Status"] != StatusEnum.APPROVED.value: + raise InvalidOperationException( + f"Request {change_request_id} is not in APPROVED status. Cannot apply." + ) + + # Permission: Admin can apply any. Merchant might apply their own if auto-apply or specific flow. + if ( + pcr_data["RequestingUserID"] != applier_user.UserID + ): + logger.warning( + f"Permission Denied: User {applier_user.UserID} cannot apply request {change_request_id} for Requester {pcr_data['RequestingUserID']}." + ) + # TODO: 使用专门的权限管理类 + + proposed_data_obj: Optional[ProposedStoreData] = None # Assuming a ProposedStoreData schema + if pcr_data["ProposedData_JSON"]: + try: + # You'll need a ProposedStoreData schema similar to ProposedProductData + # For now, assume it's a dict that StoreCRUD methods can handle + proposed_data_obj = ProposedStoreData(**pcr_data["ProposedData_JSON"]) + except Exception as e: + logger.error(f"Invalid ProposedData_JSON for SCR ID {change_request_id}: {e}") + self._scr_crud.update_request_by_admin( + conn=db, + change_request_id=change_request_id, + status=StatusEnum.REJECTED.value, # Reject the request + admin_reviewer_id=applier_user.UserID, + admin_notes=f"Application failed: Invalid proposed data. {e}", + actor_id=applier_user.UserID, + ) + raise BadRequestException(f"Invalid proposed data for request {change_request_id}.") + + applied_store_id: Optional[int] = pcr_data.get("StoreID") + request_type = RequestTypeEnum(pcr_data["RequestType"]) # Convert string from DB to Enum + + if request_type == RequestTypeEnum.STORE_CREATE: + if ( + applied_store_id is not None + ): # StoreID should be NULL for create request before application + raise InvalidOperationException( + f"StoreID should be null for STORE_CREATE request {change_request_id} before application." + ) + + # Extract data for StoreCRUD.create_store + # Assuming ProposedStoreData has StoreName, Description, LogoURL + # StoreStatus for new store might come from proposed_data or a default + # CreationDate from proposed_data or set by CRUD/DB + + # This requires a StoreCreate schema or individual params for _store_crud.create_store + # We'll assume proposed_data_dict_for_crud contains necessary fields + # and StoreCRUD.create_store can handle them. + + # Placeholder: You need to map fields from proposed_data_dict_for_crud + # to the parameters of self._store_crud.create_store + store_name = proposed_data_obj.StoreName + if not store_name: + raise BadRequestException("StoreName is required in ProposedData for STORE_CREATE.") + + # New store's status might be ACTIVE by default or from proposed_data + new_store_status = proposed_data_obj.StoreStatus + if not new_store_status: + new_store_status = StoreStatusEnum.ACTIVE + + created_store_dict = self._store_crud.create_store( + conn=db, + store_name=store_name, + owner_user_id=pcr_data["RequestingUserID"], # Owner is the requester + description=proposed_data_obj.Description, + logo_url=proposed_data_obj.LogoURL, + store_status=new_store_status, + creation_date=datetime.datetime.now( + datetime.timezone.utc + ), # Or from pcr_data["CreationTime"] + actor_id=applier_user.UserID, + ) + if not created_store_dict: + raise Exception(f"Failed to create store for ChangeRequestID {change_request_id}.") + applied_store_id = created_store_dict["StoreID"] + logger.info( + f"Store {applied_store_id} created for ChangeRequestID {change_request_id}." + ) + + elif request_type == RequestTypeEnum.STORE_UPDATE: + if applied_store_id is None: + raise InvalidOperationException( + f"StoreID is missing for STORE_UPDATE request {change_request_id}." + ) + if not proposed_data_obj: + raise BadRequestException( + "ProposedData_JSON is required for STORE_UPDATE application." + ) + + update_kwargs = proposed_data_obj.model_dump() + # Convert StoreStatus to Enum if present + if "StoreStatus" in update_kwargs and isinstance(update_kwargs["StoreStatus"], str): + try: + update_kwargs["StoreStatus"] = StoreStatusEnum(update_kwargs["StoreStatus"]) + except ValueError: + raise BadRequestException( + f"Invalid StoreStatus value in ProposedData_JSON: {update_kwargs['StoreStatus']}" + ) + + if not update_kwargs: + logger.info( + f"No fields to update in ProposedData_JSON for STORE_UPDATE request {change_request_id}." + ) + else: + # Pass individual fields to store_crud.update_store + updated_store_dict = self._store_crud.update_store( + conn=db, + store_id=applied_store_id, + actor_id=applier_user.UserID, + store_name=update_kwargs.get("StoreName"), + description=update_kwargs.get("Description"), + logo_url=update_kwargs.get("LogoURL"), + store_status=update_kwargs.get("StoreStatus"), # This expects Enum member + ) + if not updated_store_dict: + raise Exception( + f"Failed to update store {applied_store_id} for ChangeRequestID {change_request_id}." + ) + logger.info( + f"Store {applied_store_id} updated for ChangeRequestID {change_request_id}." + ) + + elif request_type == RequestTypeEnum.STORE_DELETE: + if applied_store_id is None: + raise InvalidOperationException( + f"StoreID is missing for STORE_DELETE request {change_request_id}." + ) + + # Business logic for "deleting" a store is usually setting its status + # to something like 'CLOSED_PERMANENTLY_BY_ADMIN' or 'INACTIVE_BY_MERCHANT' + # rather than a hard DB delete. + # Let's assume "delete" means setting status to CLOSED_PERMANENTLY_BY_ADMIN + closed_status = StoreStatusEnum.CLOSED_PERMANENTLY_BY_ADMIN + updated_store_dict = self._store_crud.update_store( + conn=db, + store_id=applied_store_id, + actor_id=applier_user.UserID, + store_status=closed_status, + ) + if not updated_store_dict or updated_store_dict["StoreStatus"] != closed_status.value: + raise Exception( + f"Failed to mark store {applied_store_id} as closed for ChangeRequestID {change_request_id}." + ) + logger.info( + f"Store {applied_store_id} status set to {closed_status.value} for ChangeRequestID {change_request_id}." + ) + # applied_store_id remains the ID of the now-closed store. + + # 4. 更新 ProductChangeRequest 状态为 APPLIED 和 StoreID (如果创建) + final_pcr_dict = self._scr_crud.update_request_store_id_and_status_applied( + conn=db, + change_request_id=change_request_id, + applied_store_id=( + applied_store_id if request_type == RequestTypeEnum.STORE_CREATE else None + ), + # This will be new StoreID for CREATE, or existing for UPDATE/DELETE + actor_id=applier_user.UserID, + ) + if not final_pcr_dict: + logger.error( + f"CRITICAL: Failed to update StoreChangeRequestID {change_request_id} to APPLIED after store operation." + ) + raise Exception( + f"Failed to finalize ChangeRequest {change_request_id} status to APPLIED." + ) + + logger.success( + f"StoreChangeRequestID {change_request_id} successfully applied by UserID {applier_user.UserID}." + ) + return StoreChangeRequestResponse(**final_pcr_dict) diff --git a/src/backend/test/unit/service/test_store_change_request_service_v2.py b/src/backend/test/unit/service/test_store_change_request_service_v2.py new file mode 100644 index 0000000..295f7ba --- /dev/null +++ b/src/backend/test/unit/service/test_store_change_request_service_v2.py @@ -0,0 +1,400 @@ +# src/backend/test/unit/service/test_store_change_request_service_v2.py +import unittest +from unittest.mock import MagicMock, AsyncMock, patch, ANY, call +import datetime +import json # For checking JSON serialization if needed +from decimal import Decimal +from typing import Dict, Any, Optional, List + +import pydantic + +# Adjust import paths to match your project structure +from backend.app.services.store_change_request_service_v2 import StoreChangeRequestService2 +from backend.app.crud.store_change_request_crud_v2 import StoreChangeRequestCRUD2 +from backend.app.crud.store_crud import StoreCRUD +from backend.app.crud.user_crud import UserCRUD + +from backend.app.schemas.store_change_request_schema_v2 import ( + StoreChangeRequestCreate as SCR_CreateRequest, + StoreChangeRequestResponse, + StoreChangeRequestListResponse, + StoreChangeRequestUpdateRequestByAdmin as SCR_UpdateByAdminRequest, + StoreChangeRequestQueryParams, + StoreChangeRequestTypeEnum as RequestTypeEnum, + StoreChangeRequestStatusEnum as StatusEnum, + ProposedStoreData, +) +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.store_schema import StoreStatusEnum + +from backend.app.utils.exceptions import ( + StoreNotFoundException, + ProductNotFoundException, + InvalidOperationException, + BadRequestException, + UserNotFoundException, + PermissionDeniedException, +) + +from sqlalchemy.engine.base import Connection + + +class TestStoreChangeRequestService2(unittest.IsolatedAsyncioTestCase): + + def setUp(self): + self.mock_scr_crud = MagicMock(spec=StoreChangeRequestCRUD2) + self.mock_store_crud = MagicMock(spec=StoreCRUD) + self.mock_user_crud = MagicMock(spec=UserCRUD) + + self.service = StoreChangeRequestService2( + scr_crud=self.mock_scr_crud, + store_crud=self.mock_store_crud, + user_crud=self.mock_user_crud, + ) + + self.mock_db_conn = MagicMock(spec=Connection) + + self.requesting_user_id = 1 + self.store_id = 10 + self.admin_user_id = 999 + + current_time = datetime.datetime.utcnow() + self.mock_requesting_user = CurrentUserSchema( + UserID=self.requesting_user_id, + Username="test_requester", + Email="requester@store.com", + UserRole="merchant", + RegistrationDate=current_time, + LastLoginDate=current_time, + PhoneNumber=None, + ) + self.mock_admin_user = CurrentUserSchema( + UserID=self.admin_user_id, + Username="test_admin_scr", + Email="admin_scr@system.com", + UserRole="admin", + RegistrationDate=current_time, + LastLoginDate=current_time, + PhoneNumber=None, + ) + + self.sample_store_data_from_crud = { + "StoreID": self.store_id, + "OwnerUserID": self.requesting_user_id, + "StoreName": "Requester's Test Store", + "StoreStatus": StoreStatusEnum.ACTIVE.value, + } + # ⭐ Corrected base sample data name to self.base_scr_dict_from_crud + self.base_scr_dict_from_crud = { + "ChangeRequestID": 1, + "RequestingUserID": self.requesting_user_id, + "StoreID": self.store_id, + "RequestType": RequestTypeEnum.STORE_UPDATE.value, + "ProposedData_JSON": {"StoreName": "Updated Name"}, + "Status": StatusEnum.PENDING_APPROVAL.value, + "SubmitterNotes": "A note", + "AdminReviewerID": None, + "ReviewTimestamp": None, + "AdminNotes": None, + "CreationTime": current_time, + "LastUpdatedDate": current_time, + } + self.sample_scr_data_for_service = { # For when ProposedData_JSON is already a dict + **self.base_scr_dict_from_crud, + "ProposedData_JSON": self.base_scr_dict_from_crud["ProposedData_JSON"], + } + + # --- Test submit_new_request --- + async def test_submit_new_request_store_create_success(self): + proposed_data_schema = ProposedStoreData( + StoreName="My New Store", Description="The best one" + ) + request_in = SCR_CreateRequest( + RequestType=RequestTypeEnum.STORE_CREATE, + ProposedData_JSON=proposed_data_schema, + SubmitterNotes="My first store!", + # StoreID is None for CREATE in SCR_CreateRequest, but service uses requesting_user's store if not provided + # However, your SUT for STORE_CREATE does not use request_in.StoreID, it's None for CRUD. + ) + # For STORE_CREATE, store ownership check is not on request_in.StoreID + # Instead, the new store will be owned by requesting_user. + # self.mock_store_crud.get_store_by_id is not called for STORE_CREATE in SUT's submit_new_request + + created_pcr_from_crud = { + **self.base_scr_dict_from_crud, + "ChangeRequestID": 123, + "RequestType": RequestTypeEnum.STORE_CREATE.value, + "ProposedData_JSON": proposed_data_schema.model_dump(exclude_unset=True), + "StoreID": None, + } + self.mock_scr_crud.create_request_create_store.return_value = created_pcr_from_crud + + response = await self.service.submit_new_request( + db=self.mock_db_conn, requesting_user=self.mock_requesting_user, request_in=request_in + ) + self.assertIsInstance(response, StoreChangeRequestResponse) + self.assertEqual(response.ChangeRequestID, 123) + self.assertEqual(response.RequestType, RequestTypeEnum.STORE_CREATE) + self.mock_scr_crud.create_request_create_store.assert_called_once_with( + conn=self.mock_db_conn, + requesting_user_id=self.mock_requesting_user.UserID, + # StoreID is not passed to create_request_create_store as per CRUD signature + submitter_notes=request_in.SubmitterNotes, + proposed_data_json=proposed_data_schema.model_dump(exclude_unset=True), + actor_id=self.mock_requesting_user.UserID, + ) + + async def test_submit_new_request_store_update_success(self): + proposed_data_schema = ProposedStoreData(StoreName="Updated Store Name") + request_in = SCR_CreateRequest( # This schema is used for all submission types in SUT + StoreID=self.store_id, + RequestType=RequestTypeEnum.STORE_UPDATE, + ProposedData_JSON=proposed_data_schema, + ) + self.mock_store_crud.get_store_by_id.return_value = self.sample_store_data_from_crud + + created_pcr_from_crud = { + **self.base_scr_dict_from_crud, + "ChangeRequestID": 124, + "RequestType": RequestTypeEnum.STORE_UPDATE.value, + "StoreID": self.store_id, + "ProposedData_JSON": proposed_data_schema.model_dump(exclude_unset=True), + } + self.mock_scr_crud.create_request_update_store.return_value = created_pcr_from_crud + + response = await self.service.submit_new_request( + db=self.mock_db_conn, requesting_user=self.mock_requesting_user, request_in=request_in + ) + self.assertEqual(response.ChangeRequestID, 124) + self.mock_scr_crud.create_request_update_store.assert_called_once() + + async def test_submit_new_request_store_create_missing_proposed_data_name(self): + proposed_data_no_name = ProposedStoreData(Description="A store without a name") + with self.assertRaisesRegex(pydantic.ValidationError, "validation"): + request_in = SCR_CreateRequest( + RequestType=RequestTypeEnum.STORE_CREATE, + ProposedData_JSON=proposed_data_no_name, + # StoreID is None for create + ) + + # --- Test get_request_details --- + async def test_get_request_details_success(self): + pcr_id = self.base_scr_dict_from_crud["ChangeRequestID"] + # Request belongs to the requesting_user + self.mock_scr_crud.get_request_by_id.return_value = { + **self.base_scr_dict_from_crud, + "RequestingUserID": self.mock_requesting_user.UserID, + } + + response = await self.service.get_request_details( + db=self.mock_db_conn, change_request_id=pcr_id, actor_user=self.mock_requesting_user + ) + self.assertIsInstance(response, StoreChangeRequestResponse) + self.assertEqual(response.ChangeRequestID, pcr_id) + + async def test_get_request_details_not_found_raises_exception(self): + self.mock_scr_crud.get_request_by_id.return_value = None + with self.assertRaisesRegex( + StoreNotFoundException, "StoreChangeRequest with ID 999 not found." + ): + await self.service.get_request_details( + db=self.mock_db_conn, change_request_id=999, actor_user=self.mock_requesting_user + ) + + # --- Test list_requests_for_requesting_user --- + async def test_list_requests_for_requesting_user_success(self): + query_params = StoreChangeRequestQueryParams(Status=[StatusEnum.PENDING_APPROVAL]) + self.mock_scr_crud.get_request_list.return_value = [self.base_scr_dict_from_crud] + response = await self.service.list_requests_for_requesting_user( + db=self.mock_db_conn, + requesting_user=self.mock_requesting_user, + query_params=query_params, + ) + self.assertEqual(response.TotalCount, 1) + self.mock_scr_crud.get_request_list.assert_called_once_with( + conn=self.mock_db_conn, + requesting_user_id=self.mock_requesting_user.UserID, + status_list=[StatusEnum.PENDING_APPROVAL.value], + request_type_list=None, + store_id=None, + ) + + # --- Test list_requests_for_admin --- + # @patch('backend.app.services.store_change_request_service_v2._is_actor_admin', return_value=True) + async def test_list_requests_for_admin_success(self): # Removed mock_is_admin_check + query_params = StoreChangeRequestQueryParams(StoreID=self.store_id) + self.mock_scr_crud.get_request_list.return_value = [self.base_scr_dict_from_crud] + + # Assuming _is_actor_admin is not called or permission logic is elsewhere + response = await self.service.list_requests_for_admin( + db=self.mock_db_conn, admin_user=self.mock_admin_user, query_params=query_params + ) + self.assertEqual(response.TotalCount, 1) + self.mock_scr_crud.get_request_list.assert_called_once_with( + conn=self.mock_db_conn, + status_list=None, + request_type_list=None, + store_id=self.store_id, + requesting_user_id=None, + ) + + # --- Test user_cancel_request --- + async def test_user_cancel_request_success(self): + pcr_id = self.base_scr_dict_from_crud["ChangeRequestID"] + pending_request_owned = { + **self.base_scr_dict_from_crud, + "RequestingUserID": self.mock_requesting_user.UserID, + "Status": StatusEnum.PENDING_APPROVAL.value, + } + cancelled_request_dict = { + **pending_request_owned, + "Status": StatusEnum.CANCELLED_BY_USER.value, + } + + self.mock_scr_crud.get_request_by_id.side_effect = [ + pending_request_owned, + cancelled_request_dict, + ] + self.mock_scr_crud.cancel_request_by_user.return_value = True + + response = await self.service.user_cancel_request( + db=self.mock_db_conn, + change_request_id=pcr_id, + requesting_user=self.mock_requesting_user, + ) + self.assertEqual(response.Status, StatusEnum.CANCELLED_BY_USER) + self.mock_scr_crud.cancel_request_by_user.assert_called_once_with( + conn=self.mock_db_conn, + change_request_id=pcr_id, + actor_id=self.mock_requesting_user.UserID, + ) + + # --- Test admin_review_request & apply_approved_request (combined flow) --- + # @patch('backend.app.services.store_change_request_service_v2._is_actor_admin', return_value=True) + async def test_admin_review_and_apply_store_create_success(self): # Removed mock_is_admin_func + pcr_id = self.base_scr_dict_from_crud["ChangeRequestID"] + review_data = SCR_UpdateByAdminRequest( + Status=StatusEnum.APPROVED, AdminNotes="Looks good for store creation" + ) + + proposed_data_for_create_dict = { + "StoreName": "Brand New Store From PCR", + "Description": "Desc", + "LogoURL": "logo.png", + "StoreStatus": StoreStatusEnum.ACTIVE.value, + } + pending_pcr = { + **self.base_scr_dict_from_crud, + "ChangeRequestID": pcr_id, + "Status": StatusEnum.PENDING_APPROVAL.value, + "RequestType": RequestTypeEnum.STORE_CREATE.value, + "ProposedData_JSON": proposed_data_for_create_dict, + "StoreID": None, + } + approved_pcr_from_review_crud = { + **pending_pcr, + "Status": StatusEnum.APPROVED.value, + "AdminReviewerID": self.admin_user_id, + "AdminNotes": "Looks good for store creation", + } + + self.mock_scr_crud.get_request_by_id.side_effect = [ + pending_pcr, + approved_pcr_from_review_crud, + ] + self.mock_scr_crud.update_request_by_admin.return_value = approved_pcr_from_review_crud + + created_store_from_db = { + "StoreID": 789, + "StoreName": "Brand New Store From PCR", + "OwnerUserID": pending_pcr["RequestingUserID"], + } + self.mock_store_crud.create_store.return_value = created_store_from_db + + final_applied_pcr_state = { + **approved_pcr_from_review_crud, + "Status": StatusEnum.APPLIED.value, + "StoreID": 789, + } + self.mock_scr_crud.update_request_store_id_and_status_applied = MagicMock( + return_value=final_applied_pcr_state + ) + + response = await self.service.admin_review_request( + db=self.mock_db_conn, + change_request_id=pcr_id, + admin_user=self.mock_admin_user, + review_data=review_data, + ) + + self.assertEqual(response.Status, StatusEnum.APPLIED) + self.assertEqual(response.StoreID, 789) + + self.mock_store_crud.create_store.assert_called_once() + create_store_kwargs = self.mock_store_crud.create_store.call_args.kwargs + self.assertEqual( + create_store_kwargs["store_name"], proposed_data_for_create_dict["StoreName"] + ) + + self.mock_scr_crud.update_request_store_id_and_status_applied.assert_called_once_with( + conn=self.mock_db_conn, + change_request_id=pcr_id, + applied_store_id=789, + actor_id=self.mock_admin_user.UserID, + ) + + # --- Test apply_approved_request (separate from review) --- + # @patch('backend.app.services.store_change_request_service_v2._is_actor_admin', return_value=True) + async def test_apply_approved_request_store_update_success(self): # Removed mock_is_admin_func + pcr_id = self.base_scr_dict_from_crud["ChangeRequestID"] + existing_store_id_for_update = self.store_id + + proposed_data_for_update_dict = { + "StoreName": "Super Updated Store From PCR", + "Description": "Even better now", + } + approved_pcr_for_update = { + **self.base_scr_dict_from_crud, + "ChangeRequestID": pcr_id, + "Status": StatusEnum.APPROVED.value, + "RequestType": RequestTypeEnum.STORE_UPDATE.value, + "ProposedData_JSON": proposed_data_for_update_dict, + "StoreID": existing_store_id_for_update, + } + self.mock_scr_crud.get_request_by_id.return_value = approved_pcr_for_update + + mock_updated_store_from_db = { + **self.sample_store_data_from_crud, + "StoreID": existing_store_id_for_update, + "StoreName": "Super Updated Store From PCR", + } + self.mock_store_crud.update_store.return_value = mock_updated_store_from_db + + final_applied_pcr_state = {**approved_pcr_for_update, "Status": StatusEnum.APPLIED.value} + self.mock_scr_crud.update_request_store_id_and_status_applied = MagicMock( + return_value=final_applied_pcr_state + ) + + response = await self.service.apply_approved_request( + db=self.mock_db_conn, change_request_id=pcr_id, applier_user=self.mock_admin_user + ) + self.assertEqual(response.Status, StatusEnum.APPLIED) + self.mock_store_crud.update_store.assert_called_once_with( + conn=self.mock_db_conn, + store_id=existing_store_id_for_update, + actor_id=self.mock_admin_user.UserID, + store_name=proposed_data_for_update_dict.get("StoreName"), + description=proposed_data_for_update_dict.get("Description"), + logo_url=None, # Not in proposed_data_for_update_dict + store_status=None, # Not in proposed_data_for_update_dict + ) + self.mock_scr_crud.update_request_store_id_and_status_applied.assert_called_once_with( + conn=self.mock_db_conn, + change_request_id=pcr_id, + applied_store_id=None, # StoreID is not backfilled for UPDATE by SUT + actor_id=self.mock_admin_user.UserID, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 72c4b299b96489736cd9d3a58a1f1745b72943c9 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sun, 25 May 2025 00:05:32 +0800 Subject: [PATCH 23/26] feat(endpoints): implement store change request v2 endpoints --- .../v1/endpoints/store_change_request_v2.py | 191 ++++++++++++++---- src/backend/app/api/v1/router.py | 3 +- src/backend/app/crud/__init__.py | 1 + src/backend/app/dependencies/crud_deps.py | 8 + src/backend/app/dependencies/service_deps.py | 19 ++ src/backend/app/services/__init__.py | 1 + 6 files changed, 181 insertions(+), 42 deletions(-) diff --git a/src/backend/app/api/v1/endpoints/store_change_request_v2.py b/src/backend/app/api/v1/endpoints/store_change_request_v2.py index 59a859c..8efb39d 100644 --- a/src/backend/app/api/v1/endpoints/store_change_request_v2.py +++ b/src/backend/app/api/v1/endpoints/store_change_request_v2.py @@ -6,6 +6,10 @@ # 依赖项导入 from backend.app.dependencies.auth_deps import get_current_active_user, get_db_connection +from backend.app.dependencies.service_deps import get_store_change_request_service_v2 +from backend.app.services.store_change_request_service_v2 import ( + StoreChangeRequestService2 as SCRServiceV2, +) # Schema 导入 - 使用您指定的 v2 文件名和更新后的类名 from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema @@ -23,30 +27,16 @@ from sqlalchemy.engine.base import Connection from backend.app.utils import logger -from backend.app.utils.exceptions import StoreNotFoundException, BadRequestException, \ - PermissionDeniedException - -_mock_response: StoreChangeRequestResponse = StoreChangeRequestResponse( - ChangeRequestID=1, - StoreID=123, - RequestingUserID=456, - RequestType=RequestTypeEnum.STORE_CREATE, - Status=RequestStatusEnum.PENDING_APPROVAL, - SubmitterNotes="Initial store creation request.", - ProposedData_JSON=ProposedStoreData( - StoreName="Test Store", - Description="A test store for demonstration purposes.", - LogoURL="http://example.com/logo.png", - StoreStatus=StoreStatusEnum.ACTIVE - ), - AdminReviewerID=1, - AdminNotes="Initial review by admin.", - ReviewTimestamp=datetime.datetime.now(), - CreationTime=datetime.datetime.now(), - LastUpdatedDate=datetime.datetime.now() +from backend.app.utils.exceptions import ( + StoreNotFoundException, + BadRequestException, + PermissionDeniedException, ) + router = APIRouter() + + @router.post( "/", response_model=StoreChangeRequestResponse, @@ -57,14 +47,33 @@ async def submit_store_change_request( request_in: StoreChangeRequestCreate, current_user: CurrentUserSchema = Depends(get_current_active_user), db: Connection = Depends(get_db_connection), + service: SCRServiceV2 = Depends(get_store_change_request_service_v2), ): """ 创建一个新的店铺变更请求。 需要提供店铺 ID 和变更类型(创建、更新或删除)。 """ logger.info(f"User {current_user.UserID} is submitting a store change request: {request_in}") + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + return await service.submit_new_request( + db, requesting_user=current_user, request_in=request_in, + ) + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error" + ) - return _mock_response @router.get( "/{request_id}", @@ -75,13 +84,34 @@ async def get_store_change_request( request_id: int = FastApiPath(..., description="变更请求的唯一ID"), current_user: CurrentUserSchema = Depends(get_current_active_user), db: Connection = Depends(get_db_connection), + service: SCRServiceV2 = Depends(get_store_change_request_service_v2), ): """ 获取单个店铺变更请求的详细信息。 """ - logger.info(f"User {current_user.UserID} is retrieving store change request with ID {request_id}") + logger.info( + f"User {current_user.UserID} is retrieving store change request with ID {request_id}" + ) + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + return await service.get_request_details( + db, change_request_id=request_id, actor_user=current_user, + ) + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error" + ) - return _mock_response @router.get( "/list/", @@ -92,16 +122,36 @@ async def list_store_change_requests( query_params: StoreChangeRequestQueryParams = Depends(), current_user: CurrentUserSchema = Depends(get_current_active_user), db: Connection = Depends(get_db_connection), + service: SCRServiceV2 = Depends(get_store_change_request_service_v2), ): """ 获取店铺变更请求列表,可以按状态、类型等筛选。 """ - logger.info(f"User {current_user.UserID} is listing store change requests with filters: {query_params}") - - return StoreChangeRequestListResponse( - Requests=[_mock_response], - TotalCount=1 + logger.info( + f"User {current_user.UserID} is listing store change requests with filters: {query_params}" ) + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + return await service.list_requests_for_requesting_user( + db, + requesting_user=current_user, + query_params=query_params, + ) + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error" + ) + @router.get( "/list-admin/", @@ -112,16 +162,36 @@ async def list_store_change_requests_admin( query_params: StoreChangeRequestQueryParams = Depends(), current_user: CurrentUserSchema = Depends(get_current_active_user), db: Connection = Depends(get_db_connection), + service: SCRServiceV2 = Depends(get_store_change_request_service_v2), ): """ 管理员获取店铺变更请求列表,可以按状态、类型等筛选。 """ - logger.info(f"Admin {current_user.UserID} is listing store change requests with filters: {query_params}") - - return StoreChangeRequestListResponse( - Requests=[_mock_response], - TotalCount=1 + logger.info( + f"Admin {current_user.UserID} is listing store change requests with filters: {query_params}" ) + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + return await service.list_requests_for_admin( + db, + admin_user=current_user, + query_params=query_params, + ) + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error" + ) + @router.delete( "/{request_id}", @@ -132,15 +202,34 @@ async def cancel_store_change_request( request_id: int = FastApiPath(..., description="变更请求的唯一ID"), current_user: CurrentUserSchema = Depends(get_current_active_user), db: Connection = Depends(get_db_connection), + service: SCRServiceV2 = Depends(get_store_change_request_service_v2), ): """ 删除或取消一个店铺变更请求。 只有提交请求的用户或管理员可以执行此操作。 """ - logger.info(f"User {current_user.UserID} is cancelling store change request with ID {request_id}") - - # 模拟删除操作 - return _mock_response + logger.info( + f"User {current_user.UserID} is cancelling store change request with ID {request_id}" + ) + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + return await service.user_cancel_request( + db, change_request_id=request_id, requesting_user=current_user, + ) + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error" + ) @router.put( @@ -153,11 +242,31 @@ async def review_store_change_request( request_id: int = FastApiPath(..., description="变更请求的唯一ID"), admin_user: CurrentUserSchema = Depends(get_current_active_user), db: Connection = Depends(get_db_connection), + service: SCRServiceV2 = Depends(get_store_change_request_service_v2), ): """ 管理员审核店铺变更请求,更新状态和备注。 """ logger.info(f"Admin {admin_user.UserID} is reviewing store change request with ID {request_id}") - - # 模拟审核操作 - return _mock_response + try: + with db.begin_nested() if db.in_transaction() else db.begin(): + return await service.admin_review_request( + db, + change_request_id=request_id, + admin_user=admin_user, + review_data=review_data, + ) + except StoreNotFoundException as e: + logger.error(f"Store not found: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BadRequestException as e: + logger.error(f"Bad request: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionDeniedException as e: + logger.error(f"Permission denied: {e}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error" + ) diff --git a/src/backend/app/api/v1/router.py b/src/backend/app/api/v1/router.py index 924fd81..fa3b2b4 100644 --- a/src/backend/app/api/v1/router.py +++ b/src/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter from .endpoints import user, product, category, auth, cart, address, order, payment, store,\ - store_change_request, product_change_request, product_change_request_v2 + store_change_request, product_change_request, product_change_request_v2, store_change_request_v2 api_router_v1 = APIRouter() @@ -15,6 +15,7 @@ api_router_v1.include_router(payment.router, prefix="/payment", tags=["Payments"]) api_router_v1.include_router(store.router, prefix="/store", tags=["Store"]) api_router_v1.include_router(store_change_request.router, prefix="/store-change", tags=["Store Change Requests"]) +api_router_v1.include_router(store_change_request_v2.router, prefix="/store-change-new", tags=["Store Change Requests V2"]) api_router_v1.include_router(product_change_request.router, prefix="/product-change", tags=["Product Change Requests V1"]) diff --git a/src/backend/app/crud/__init__.py b/src/backend/app/crud/__init__.py index 6531cd9..4df3321 100644 --- a/src/backend/app/crud/__init__.py +++ b/src/backend/app/crud/__init__.py @@ -12,3 +12,4 @@ from .payment_transaction_crud import PaymentTransactionCRUD from .store_crud import StoreCRUD from .product_change_request_crud_v2 import ProductChangeRequestCRUD2 +from .store_change_request_crud_v2 import StoreChangeRequestCRUD2 diff --git a/src/backend/app/dependencies/crud_deps.py b/src/backend/app/dependencies/crud_deps.py index 1c76d8e..17e61c8 100644 --- a/src/backend/app/dependencies/crud_deps.py +++ b/src/backend/app/dependencies/crud_deps.py @@ -10,6 +10,7 @@ PaymentTransactionCRUD, StoreCRUD, ProductChangeRequestCRUD2, + StoreChangeRequestCRUD2, ) @@ -91,3 +92,10 @@ def get_product_change_request_crud2() -> ProductChangeRequestCRUD2: :return: The ProductChangeRequestCRUD2 instance. """ return ProductChangeRequestCRUD2.get_instance() + +def get_store_change_request_crud2() -> StoreChangeRequestCRUD2: + """ + Dependency to get the StoreChangeRequestCRUD2 instance. + :return: The StoreChangeRequestCRUD2 instance. + """ + return StoreChangeRequestCRUD2.get_instance() diff --git a/src/backend/app/dependencies/service_deps.py b/src/backend/app/dependencies/service_deps.py index 53819cb..5704dce 100644 --- a/src/backend/app/dependencies/service_deps.py +++ b/src/backend/app/dependencies/service_deps.py @@ -24,6 +24,7 @@ ProductChangeRequestService, StoreService, ProductChangeRequestService2, + StoreChangeRequestService2, ) from backend.app.dependencies.crud_deps import * from backend.app.crud.store_change_request_crud import get_store_change_request_crud_instance @@ -213,3 +214,21 @@ def get_product_change_request_service_v2( user_crud=user_crud, store_crud=store_crud, ) + +def get_store_change_request_service_v2( + store_change_request_crud: StoreChangeRequestCRUD2 = Depends(get_store_change_request_crud2), + store_crud: StoreCRUD = Depends(get_store_crud), + user_crud: UserCRUD = Depends(get_user_crud), +) -> StoreChangeRequestService2: + """ + Dependency to get the StoreChangeRequestService2 instance. + :param store_change_request_crud: The StoreChangeRequestCRUD2 instance. + :param store_crud: The StoreCRUD instance. + :param user_crud: The UserCRUD instance. + :return: The StoreChangeRequestService2 instance. + """ + return StoreChangeRequestService2( + scr_crud=store_change_request_crud, + store_crud=store_crud, + user_crud=user_crud, + ) diff --git a/src/backend/app/services/__init__.py b/src/backend/app/services/__init__.py index 85d2010..8577c37 100644 --- a/src/backend/app/services/__init__.py +++ b/src/backend/app/services/__init__.py @@ -9,3 +9,4 @@ from .store_service import StoreService from .product_change_request_service_v2 import ProductChangeRequestService2 +from .store_change_request_service_v2 import StoreChangeRequestService2 From 59cd07dff1d70f30b53ccbcb58fe68cc70af09b6 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sun, 25 May 2025 00:41:24 +0800 Subject: [PATCH 24/26] test(store_change_request_endpoints_v2): add integration tests for store change request endpoints --- .../test_store_change_request_endpoints_v2.py | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py diff --git a/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py b/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py new file mode 100644 index 0000000..9a103e9 --- /dev/null +++ b/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py @@ -0,0 +1,477 @@ +# src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py +import unittest +import datetime +import uuid +from decimal import Decimal +from typing import Dict, Any, Optional, List +import asyncio + +from fastapi import FastAPI, Depends, status +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.engine.base import Connection + +# Adjust import paths to match your project structure +from backend.test.base_db_testcase import AsyncBaseDBTestCaseAutoRollback +from backend.app.main import app # Your FastAPI main application instance +from backend.app.schemas.user_schema import UserResponse as CurrentUserSchema +from backend.app.schemas.store_change_request_schema_v2 import ( + StoreChangeRequestCreate, + StoreChangeRequestResponse, + StoreChangeRequestListResponse, + StoreChangeRequestUpdateRequestByAdmin, + StoreChangeRequestQueryParams, + StoreChangeRequestStatusEnum as RequestStatusEnum, + StoreChangeRequestTypeEnum as RequestTypeEnum, + ProposedStoreData, +) +from backend.app.schemas.store_schema import StoreStatusEnum +from backend.app.services.store_change_request_service_v2 import ( + StoreChangeRequestService2 as SCRServiceV2, +) +from backend.app.crud.user_crud import UserCRUD +from backend.app.crud.store_crud import StoreCRUD +from backend.app.crud.product_crud import ( + ProductCRUD, +) +from backend.app.crud.store_change_request_crud_v2 import ( + StoreChangeRequestCRUD2 as SCR_TableCRUD, +) + +from backend.app.dependencies.auth_deps import get_current_active_user, get_db_connection +from backend.app.dependencies.service_deps import ( + get_store_change_request_service_v2 as get_scr_service, + get_store_change_request_service_v2, # Alias for clarity +) + +from backend.app.utils.security import hash_password +from backend.app.utils.exceptions import StoreNotFoundException, BadRequestException +from backend.app.core import database as core_db_module +from backend.app.utils import logger + + +class TestStoreChangeRequestEndpointsIntegration(AsyncBaseDBTestCaseAutoRollback): + + # --- Class-level shared data IDs --- + requesting_user_id_cls: int = 30 # Merchant + admin_user_id_cls: int = 31 # Admin + + store_id_cls: int = 3001 # This is an existing store owned by requesting_user_id_cls + store_name_cls: str = "SCR Integ Test Store" + + category_id_cls: int = 2101 # Example, not directly used by StoreChangeRequest + product2_id_cls: int = 2202 + product2_price_cls: Decimal = Decimal("199.99") + + @classmethod + def _create_shared_class_data(cls, conn: Connection): + logger.info(f"--- {cls.__name__}: Creating shared class-level SCR data ---") + try: + # 1. Create Users + users_data = [ + { + "UserID": cls.requesting_user_id_cls, + "Username": "scr_requester_integ", + "PasswordHash": hash_password("RequesterPass1!"), + "Email": "scr_requester@example.com", + "UserRole": "merchant", + }, + { + "UserID": cls.admin_user_id_cls, + "Username": "scr_admin_integ", + "PasswordHash": hash_password("AdminSCRPass1!"), + "Email": "scr_admin@example.com", + "UserRole": "admin", + }, + ] + for ud in users_data: + conn.execute( + text( + "INSERT INTO User (UserID, Username, PasswordHash, Email, UserRole, AccountStatus) VALUES (:UserID, :Username, :PasswordHash, :Email, :UserRole, 'ACTIVE') ON DUPLICATE KEY UPDATE Username=VALUES(Username)" + ), + ud, + ) + + # 2. Create Store (owned by requesting_user_id_cls) + conn.execute( + text( + "INSERT INTO Store (StoreID, StoreName, OwnerUserID, StoreStatus) VALUES (:id, :name, :owner_id, 'ACTIVE') ON DUPLICATE KEY UPDATE StoreName=VALUES(StoreName)" + ), + { + "id": cls.store_id_cls, + "name": cls.store_name_cls, + "owner_id": cls.requesting_user_id_cls, + }, + ) + # Create a category and product for completeness, though not directly tested by all SCR endpoints + conn.execute( + text( + "INSERT INTO ProductCategory (CategoryID, CategoryName) VALUES (:id, 'SCR Integ Cat') ON DUPLICATE KEY UPDATE CategoryName=VALUES(CategoryName)" + ), + {"id": cls.category_id_cls}, + ) + conn.execute( + text( + "INSERT INTO Product (ProductID, ProductName, Price, StoreID, CategoryID, StockQuantity) VALUES (:id, :name, :price, :sid, :cid, 50) ON DUPLICATE KEY UPDATE ProductName=VALUES(ProductName)" + ), + { + "id": cls.product2_id_cls, + "name": "Existing Product for SCR Integ", + "price": cls.product2_price_cls, + "sid": cls.store_id_cls, + "cid": cls.category_id_cls, + }, + ) + logger.info(f"--- {cls.__name__}: Shared class-level SCR data creation complete ---") + except Exception as e: + logger.error(f"ERROR during {cls.__name__}._create_shared_class_data: {e}") + raise + + @classmethod + def setUpClass(cls): + super().setUpClass() + conn_for_class_setup: Optional[Connection] = None + try: + conn_for_class_setup = cls.engine.connect() + with conn_for_class_setup.begin(): + cls._create_shared_class_data(conn_for_class_setup) + except Exception as e: + logger.error(f"ERROR in {cls.__name__}.setUpClass during shared data setup: {e}") + raise + finally: + if conn_for_class_setup: + conn_for_class_setup.close() + + async def asyncSetUp(self): + await super().asyncSetUp() + + current_utc_time = datetime.datetime.now(datetime.timezone.utc) + self.mock_requesting_user_schema = CurrentUserSchema( + UserID=self.requesting_user_id_cls, + Username="scr_requester_integ", + Email="scr_requester@example.com", + PhoneNumber=None, + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + UserRole="merchant", + ) + self.mock_admin_user_schema = CurrentUserSchema( + UserID=self.admin_user_id_cls, + Username="scr_admin_integ", + Email="scr_admin@example.com", + PhoneNumber=None, + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + UserRole="admin", + ) + + def override_get_db_connection() -> Connection: + return self.connection + + self.real_scr_crud = SCR_TableCRUD.get_instance() + self.real_store_crud = StoreCRUD.get_instance() + self.real_user_crud = UserCRUD.get_instance() + self.real_product_crud = ProductCRUD.get_instance() # Needed by service for apply + + self.real_scr_service = SCRServiceV2( + scr_crud=self.real_scr_crud, + store_crud=self.real_store_crud, + user_crud=self.real_user_crud, + ) + + def override_get_scr_service() -> SCRServiceV2: + return self.real_scr_service + + async def override_get_current_active_user_default() -> CurrentUserSchema: + return self.mock_requesting_user_schema + + app.dependency_overrides[get_db_connection] = override_get_db_connection + app.dependency_overrides[get_store_change_request_service_v2] = override_get_scr_service + app.dependency_overrides[get_current_active_user] = override_get_current_active_user_default + + self.client = TestClient(app) + + async def asyncTearDown(self): + app.dependency_overrides.clear() + await super().asyncTearDown() + + async def _create_direct_scr_via_api( + self, + request_type: RequestTypeEnum, + proposed_data_json: Optional[ProposedStoreData] = None, # Changed from ProposedProductData + store_id_in_payload: Optional[ + int + ] = None, # This is the StoreID in the SCR_CreateRequest payload + submitter_notes: Optional[str] = None, + # ProductID is not part of StoreChangeRequestCreate schema + ) -> Dict[str, Any]: + """Helper to create a StoreChangeRequest via API and return its JSON response.""" + + # Construct the payload for StoreChangeRequestCreate + # StoreID in StoreChangeRequestCreate is required. + # For STORE_CREATE, service expects request_in.StoreID to be None if DDL StoreID is None. + # However, your endpoint's StoreChangeRequestCreate schema has StoreID as required. + # The service logic: `if request_in.RequestType == RequestTypeEnum.STORE_CREATE: if request_in.StoreID is not None: raise BadRequestException` + # This means for STORE_CREATE, the payload's StoreID *must* be None. + # But the schema StoreChangeRequestCreate has StoreID: int = Field(...). This is a conflict. + # I will assume for STORE_CREATE, the helper should pass StoreID=None to the schema, + # which will fail Pydantic validation if StoreID is not Optional in StoreChangeRequestCreate. + # + # Based on your SUT for store_change_request_v2.py: + # StoreChangeRequestCreate has StoreID: int (required) + # Service's submit_new_request for STORE_CREATE: + # - checks store ownership using request_in.StoreID (so it must be provided) + # - then checks `if request_in.StoreID is not None: raise BadRequestException` (this is contradictory) + # + # I will proceed assuming the service's check `if request_in.StoreID is not None: raise BadRequestException` for STORE_CREATE + # implies that the StoreID in the *payload* should be None for that type, and the service + # uses requesting_user.UserID to determine ownership context for the new store. + # This means the StoreChangeRequestCreate schema should ideally have StoreID as Optional. + # For this helper, I will construct the payload based on what the *endpoint* expects. + # The endpoint uses the StoreChangeRequestCreate schema from product_change_request_schema_v2.py + # which has StoreID: int (required). + + payload_for_api: Dict[str, Any] = { + "RequestType": request_type.value, + "SubmitterNotes": submitter_notes, + "StoreID": store_id_in_payload + } + # The StoreID in the payload for the API. + # For STORE_CREATE, the service expects this to be the store the merchant owns, + # under which they are *allowed* to create a new store (even if the SCR.StoreID in DB is NULL). + # For UPDATE/DELETE, this is the target store. + + if proposed_data_json: + payload_for_api["ProposedData_JSON"] = proposed_data_json.model_dump( + mode="json", exclude_none=True + ) + + # The StoreChangeRequestCreate schema from product_change_request_schema_v2 also has an optional ProductID. + # We will omit it as it's not relevant for Store Change Requests. + # If your actual StoreChangeRequestCreate schema (used by the store endpoint) + # does not have ProductID, then this is fine. + + scr_create_payload = StoreChangeRequestCreate(**payload_for_api) + + # set get_current_user DI as mock_requesting_user_schema + old_dependency = app.dependency_overrides.get(get_current_active_user, None) + app.dependency_overrides[get_current_active_user] = lambda: self.mock_requesting_user_schema + response = self.client.post( + "/api/v1/store-change-new/", # Endpoint prefix from your main router + json=scr_create_payload.model_dump(mode="json", exclude_none=True), + ) + # reset back + app.dependency_overrides[get_current_active_user] = old_dependency + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + f"Helper _create_direct_scr_via_api failed: {response.text}", + ) + return response.json() + + # --- I. POST / (submit_store_change_request) --- + async def test_submit_scr_store_create_success(self): + payload = StoreChangeRequestCreate( + StoreID=None, + RequestType=RequestTypeEnum.STORE_CREATE, + ProposedData_JSON=ProposedStoreData( + StoreName="API New Store by Merchant", + Description="A brand new store", + StoreStatus=StoreStatusEnum.ACTIVE, + ), + SubmitterNotes="My application for a new store", + ) + response = self.client.post( + "/api/v1/store-change-new/", json=payload.model_dump(mode="json") + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + data = response.json() + self.assertEqual(data["RequestType"], RequestTypeEnum.STORE_CREATE.value) + self.assertEqual(data["Status"], RequestStatusEnum.PENDING_APPROVAL.value) + self.assertEqual(data["RequestingUserID"], self.requesting_user_id_cls) + # For STORE_CREATE, the StoreID in the *ProductChangeRequest table* is initially NULL. + # The StoreID in the *payload* (request_in.StoreID) is used by the service for ownership/context. + self.assertIsNone(data["StoreID"]) + self.assertIsNotNone(data["ProposedData_JSON"]) + self.assertEqual(data["ProposedData_JSON"]["StoreName"], "API New Store by Merchant") + + async def test_submit_scr_store_update_success(self): + payload = StoreChangeRequestCreate( + StoreID=self.store_id_cls, # Target existing store owned by merchant + RequestType=RequestTypeEnum.STORE_UPDATE, + ProposedData_JSON=ProposedStoreData( + Description="Updated description via API for store." + ), + SubmitterNotes="Store description update.", + ) + response = self.client.post( + "/api/v1/store-change-new/", json=payload.model_dump(mode="json") + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + data = response.json() + self.assertEqual(data["RequestType"], RequestTypeEnum.STORE_UPDATE.value) + self.assertEqual(data["StoreID"], self.store_id_cls) # StoreID is the target store + self.assertEqual( + data["ProposedData_JSON"]["Description"], "Updated description via API for store." + ) + + async def test_submit_scr_store_update_fails_if_payload_store_id_is_none(self): + # The StoreChangeRequestCreate schema requires StoreID. + # This test checks Pydantic validation at the edge. + invalid_payload_dict = { + # "StoreID": None, # Missing required StoreID for the schema + "RequestType": RequestTypeEnum.STORE_UPDATE.value, + "ProposedData_JSON": { + "StoreName": "Test Store", + "Description": "Test", + "StoreStatus": "ACTIVE", + }, + } + response = self.client.post("/api/v1/store-change-new/", json=invalid_payload_dict) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY, response.text) + logger.debug(response.json()) + self.assertIn("StoreID", response.json()["detail"][0]["msg"]) + + # --- II. GET /list/ (list_store_change_requests by user/merchant) --- + async def test_list_scr_for_requesting_user_success(self): + # Create a request for the current merchant (user1) + await self._create_direct_scr_via_api( + request_type=RequestTypeEnum.STORE_UPDATE, + store_id_in_payload=self.store_id_cls, # StoreID for context/ownership + proposed_data_json=ProposedStoreData(StoreName="Store A", Description="Desc A"), + ) + + response = self.client.get( + f"/api/v1/store-change-new/list/?Status={RequestStatusEnum.PENDING_APPROVAL.value}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertGreaterEqual(data["TotalCount"], 1) + self.assertTrue( + all(req["RequestingUserID"] == self.requesting_user_id_cls for req in data["Requests"]) + ) + self.assertTrue( + all( + req["Status"] == RequestStatusEnum.PENDING_APPROVAL.value + for req in data["Requests"] + ) + ) + + # --- III. GET /list-admin/ (list_store_change_requests_admin) --- + async def test_list_scr_for_admin_success(self): + app.dependency_overrides[get_current_active_user] = lambda: self.mock_admin_user_schema + + await self._create_direct_scr_via_api( + request_type=RequestTypeEnum.STORE_CREATE, + store_id_in_payload=None, + proposed_data_json=ProposedStoreData(StoreName="Store B", Description="Desc B"), + ) + + response = self.client.get( + f"/api/v1/store-change-new/list-admin/?RequestingUserID={self.requesting_user_id_cls}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertGreaterEqual(data["TotalCount"], 1) + self.assertTrue( + all(req["RequestingUserID"] == self.requesting_user_id_cls for req in data["Requests"]) + ) + + # --- IV. GET /{request_id} --- + async def test_get_scr_details_success_owner(self): + created_pcr_json = await self._create_direct_scr_via_api( + request_type=RequestTypeEnum.STORE_CREATE, + store_id_in_payload=None, + proposed_data_json=ProposedStoreData(StoreName="Store C"), + ) + pcr_id = created_pcr_json["ChangeRequestID"] + + response = self.client.get(f"/api/v1/store-change-new/{pcr_id}") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertEqual(data["ChangeRequestID"], pcr_id) + self.assertEqual(data["RequestingUserID"], self.requesting_user_id_cls) + + # --- V. PUT /{request_id}/review (Admin Review) --- + async def test_admin_review_request_approve_and_apply_store_create_success(self): + app.dependency_overrides[get_current_active_user] = lambda: self.mock_admin_user_schema + + proposed_data = ProposedStoreData( + StoreName="API Approved New Store", + Description="Desc", + LogoURL="logo.png", + StoreStatus=StoreStatusEnum.ACTIVE, + ) + created_pcr = ( + await self._create_direct_scr_via_api( # This helper uses the merchant user by default + request_type=RequestTypeEnum.STORE_CREATE, + store_id_in_payload=None, + proposed_data_json=proposed_data, + ) + ) + # Manually set status to PENDING_APPROVAL if helper doesn't, or create a direct DB entry + self.real_scr_crud.update_request_by_admin( + self.connection, + change_request_id=created_pcr["ChangeRequestID"], + status=RequestStatusEnum.PENDING_APPROVAL.value, + admin_reviewer_id=self.admin_user_id_cls, + admin_notes="Set to pending for test", + actor_id=self.admin_user_id_cls, + ) + # self.connection.commit() # Not needed with transactional tests + + pcr_id = created_pcr["ChangeRequestID"] + + review_payload = StoreChangeRequestUpdateRequestByAdmin( + Status=RequestStatusEnum.APPROVED, AdminNotes="Approved via API by admin." + ) + response = self.client.put( + f"/api/v1/store-change-new/{pcr_id}/review", + json=review_payload.model_dump(mode="json"), + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + data = response.json() + self.assertEqual(data["Status"], RequestStatusEnum.APPLIED.value) + self.assertIsNotNone(data["StoreID"]) + newly_created_store_id = data["StoreID"] + + store_db = self.real_store_crud.get_store_by_id( + self.connection, store_id=newly_created_store_id + ) + self.assertIsNotNone(store_db) + self.assertEqual(store_db["StoreName"], "API Approved New Store") + self.assertEqual(store_db["OwnerUserID"], self.requesting_user_id_cls) + + # --- VI. DELETE /{request_id} (User/Merchant Cancel) --- + + async def test_delete_request_cancels_by_user_success(self): + # Current user is merchant1 (default override in setUp) + created_pcr = await self._create_direct_scr_via_api( + request_type=RequestTypeEnum.STORE_CREATE, + store_id_in_payload=None, # create a new store, so StoreID in payload is None + proposed_data_json=ProposedStoreData(StoreName="To Be Cancelled"), + ) + # Manually set status to PENDING_APPROVAL for this test + self.real_scr_crud.update_request_by_admin( + self.connection, + change_request_id=created_pcr["ChangeRequestID"], + status=RequestStatusEnum.PENDING_APPROVAL.value, + admin_reviewer_id=self.admin_user_id_cls, + admin_notes="Set to pending for cancel test", + actor_id=self.admin_user_id_cls, + ) + # self.connection.commit() + + pcr_id = created_pcr["ChangeRequestID"] + + response = self.client.delete(f"/api/v1/store-change-new/{pcr_id}") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT, response.text) + + pcr_db = self.real_scr_crud.get_request_by_id(self.connection, change_request_id=pcr_id) + self.assertIsNotNone(pcr_db) + self.assertEqual(pcr_db["Status"], RequestStatusEnum.CANCELLED_BY_USER.value) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 58174047bf755834012fe61a3973fa8efd32e648 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sun, 25 May 2025 01:06:17 +0800 Subject: [PATCH 25/26] feat(user_crud): add update_user_role method to modify user roles --- src/backend/app/crud/user_crud.py | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/backend/app/crud/user_crud.py b/src/backend/app/crud/user_crud.py index 61336cd..5ceb5b9 100644 --- a/src/backend/app/crud/user_crud.py +++ b/src/backend/app/crud/user_crud.py @@ -279,5 +279,47 @@ def update_user_default_address_id( return False return True + def update_user_role( + self, + conn: Connection, + *, + user_id: int, + new_role: str, + actor_id: Optional[int] = None, + ) -> bool: + """ + Update the role of a user. TODO: testme + :param conn: Database connection + :param user_id: The ID of the user to update. + :param new_role: The new role to set for the user. + :param actor_id: The ID of the actor (user) performing the operation. + :return: True if the update was successful, False otherwise. + """ + self._set_actor_session_variable(conn, actor_id) + + update_stmt = text(f""" + UPDATE {self.table_name} + SET UserRole = :new_role + WHERE UserID = :user_id + """) + + try: + result = conn.execute( + update_stmt, + { + "new_role": new_role, + "user_id": user_id + } + ) + except Exception as e: + logger.error(f"Failed to update UserRole for UserID: {user_id}. Error: {e}") + return False + + if result.rowcount == 0: + logger.error(f"Failed to update UserRole for UserID: {user_id}. " + f"This may be due to the user not existing.") + return False + return True + user_crud_instance = UserCRUD.get_instance() # Still expose a global variable From 5ae94467a53a6a943a90f180cc09bc66a4042829 Mon Sep 17 00:00:00 2001 From: MisakaVan <2102315149@qq.com> Date: Sun, 25 May 2025 01:06:28 +0800 Subject: [PATCH 26/26] feat(store_change_request_service_v2): update user role to merchant upon store creation approval --- .../store_change_request_service_v2.py | 23 +++++++ .../test_store_change_request_endpoints_v2.py | 69 ++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/backend/app/services/store_change_request_service_v2.py b/src/backend/app/services/store_change_request_service_v2.py index fc3809c..7ba5213 100644 --- a/src/backend/app/services/store_change_request_service_v2.py +++ b/src/backend/app/services/store_change_request_service_v2.py @@ -475,6 +475,29 @@ async def apply_approved_request( f"Store {applied_store_id} created for ChangeRequestID {change_request_id}." ) + # change the user's role to `merchant` if they used to be only `customer` + user_to_update = self._user_crud.get_user_by_id( + conn=db, user_id=pcr_data["RequestingUserID"] + ) + if not user_to_update: + raise UserNotFoundException( + f"User {pcr_data['RequestingUserID']} not found for updating role to merchant." + ) + if user_to_update["UserRole"] == "customer": + update_success = self._user_crud.update_user_role( + conn=db, + user_id=pcr_data["RequestingUserID"], + new_role="merchant", + actor_id=applier_user.UserID, + ) + if not update_success: + raise Exception( + f"Failed to update user {pcr_data['RequestingUserID']} role to merchant." + ) + logger.info( + f"User {pcr_data['RequestingUserID']} role updated to merchant after store creation." + ) + elif request_type == RequestTypeEnum.STORE_UPDATE: if applied_store_id is None: raise InvalidOperationException( diff --git a/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py b/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py index 9a103e9..f723849 100644 --- a/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py +++ b/src/backend/test/integration/api_endpoints/test_store_change_request_endpoints_v2.py @@ -55,6 +55,7 @@ class TestStoreChangeRequestEndpointsIntegration(AsyncBaseDBTestCaseAutoRollback # --- Class-level shared data IDs --- requesting_user_id_cls: int = 30 # Merchant admin_user_id_cls: int = 31 # Admin + customer_id_cls: int = 32 store_id_cls: int = 3001 # This is an existing store owned by requesting_user_id_cls store_name_cls: str = "SCR Integ Test Store" @@ -83,6 +84,13 @@ def _create_shared_class_data(cls, conn: Connection): "Email": "scr_admin@example.com", "UserRole": "admin", }, + { + "UserID": cls.customer_id_cls, + "Username": "scr_customer_integ", + "PasswordHash": hash_password("CustomerSCRPass1!"), + "Email": "customer@example.com", + "UserRole": "customer", + } ] for ud in users_data: conn.execute( @@ -164,6 +172,15 @@ async def asyncSetUp(self): LastLoginDate=current_utc_time, UserRole="admin", ) + self.mock_customer_user_schema = CurrentUserSchema( + UserID=self.customer_id_cls, + Username="scr_customer_integ", + Email="customer@example.com", + PhoneNumber=None, + RegistrationDate=current_utc_time, + LastLoginDate=current_utc_time, + UserRole="customer", + ) def override_get_db_connection() -> Connection: return self.connection @@ -204,6 +221,7 @@ async def _create_direct_scr_via_api( ] = None, # This is the StoreID in the SCR_CreateRequest payload submitter_notes: Optional[str] = None, # ProductID is not part of StoreChangeRequestCreate schema + as_user: Optional[CurrentUserSchema] = None, ) -> Dict[str, Any]: """Helper to create a StoreChangeRequest via API and return its JSON response.""" @@ -255,7 +273,8 @@ async def _create_direct_scr_via_api( # set get_current_user DI as mock_requesting_user_schema old_dependency = app.dependency_overrides.get(get_current_active_user, None) - app.dependency_overrides[get_current_active_user] = lambda: self.mock_requesting_user_schema + active_user = as_user if as_user is not None else self.mock_requesting_user_schema + app.dependency_overrides[get_current_active_user] = lambda: active_user response = self.client.post( "/api/v1/store-change-new/", # Endpoint prefix from your main router json=scr_create_payload.model_dump(mode="json", exclude_none=True), @@ -443,6 +462,54 @@ async def test_admin_review_request_approve_and_apply_store_create_success(self) self.assertEqual(store_db["StoreName"], "API Approved New Store") self.assertEqual(store_db["OwnerUserID"], self.requesting_user_id_cls) + async def test_admin_review_request_approve_customer_create_becomes_merchant(self): + app.dependency_overrides[get_current_active_user] = lambda: self.mock_admin_user_schema + # customer user is in the class setup + proposed_data = ProposedStoreData( + StoreName="API Approved New Store for Customer", + Description="Desc", + LogoURL="logo.png", + StoreStatus=StoreStatusEnum.ACTIVE, + ) + created_pcr = ( + await self._create_direct_scr_via_api( + request_type=RequestTypeEnum.STORE_CREATE, + store_id_in_payload=None, + proposed_data_json=proposed_data, + as_user=self.mock_customer_user_schema + ) + ) + # Manually set status to PENDING_APPROVAL if helper doesn't, or create a direct DB entry + self.real_scr_crud.update_request_by_admin( + self.connection, + change_request_id=created_pcr["ChangeRequestID"], + status=RequestStatusEnum.PENDING_APPROVAL.value, + admin_reviewer_id=self.admin_user_id_cls, + admin_notes="Set to pending for test", + actor_id=self.admin_user_id_cls, + ) + # self.connection.commit() # Not needed with transactional tests + + pcr_id = created_pcr["ChangeRequestID"] + + review_payload = StoreChangeRequestUpdateRequestByAdmin( + Status=RequestStatusEnum.APPROVED, AdminNotes="Approved via API by admin." + ) + response = self.client.put( + f"/api/v1/store-change-new/{pcr_id}/review", + json=review_payload.model_dump(mode="json"), + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + # assert now user is a merchant + updated_user = self.real_user_crud.get_user_by_id( + conn=self.connection, + user_id=self.mock_customer_user_schema.UserID + ) + self.assertIsNotNone(updated_user) + self.assertEqual(updated_user["UserRole"], "merchant", "Customer should become merchant after store creation approval") + + # --- VI. DELETE /{request_id} (User/Merchant Cancel) --- async def test_delete_request_cancels_by_user_success(self):