diff --git a/openspec/changes/update-asset-exchange-generation-links/proposal.md b/openspec/changes/update-asset-exchange-generation-links/proposal.md index 28f52e3..251c43c 100644 --- a/openspec/changes/update-asset-exchange-generation-links/proposal.md +++ b/openspec/changes/update-asset-exchange-generation-links/proposal.md @@ -24,3 +24,5 @@ - `src/views/asset-management/asset-information/components/BasicInfoCard.vue` - API contract: - `GET /api/admin/assets/resolve/{identifier}` + - Response field: `exchange_trace.previous_asset` and `exchange_trace.next_asset` + - Related asset item fields: `asset_type:string`, `asset_id:int64|null`, `identifier:string`, `exchange_no:string`, `can_view:bool` diff --git a/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md b/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md index d230a1c..368369b 100644 --- a/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md +++ b/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md @@ -2,7 +2,7 @@ ### Requirement: Asset Exchange Generation Trace Display -The asset information page SHALL display exchange generation relationships exclusively from `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. The frontend MUST NOT infer the exchange chain from asset status, generation, or exchange status. +The asset information page SHALL display exchange generation relationships exclusively from `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. The frontend MUST NOT infer the exchange chain from asset status, generation, exchange order status, or any field outside `exchange_trace`. #### Scenario: Display previous asset for a replacement asset @@ -10,6 +10,7 @@ The asset information page SHALL display exchange generation relationships exclu - **WHEN** 解析响应包含非空的 `exchange_trace.previous_asset` - **THEN** 页面 MUST 显示“换货新资产”标签 - **AND** 页面 MUST 展示前代资产的标识符和换货单号 +- **AND** 页面 MUST use `previous_asset.identifier` and `previous_asset.exchange_no` as the displayed source values #### Scenario: Display next asset for a replaced asset @@ -17,12 +18,14 @@ The asset information page SHALL display exchange generation relationships exclu - **WHEN** 解析响应包含非空的 `exchange_trace.next_asset` - **THEN** 页面 MUST 显示“已换出旧资产”标签 - **AND** 页面 MUST 展示后代资产的标识符和换货单号 +- **AND** 页面 MUST use `next_asset.identifier` and `next_asset.exchange_no` as the displayed source values #### Scenario: Display both relationships for an intermediate asset - **GIVEN** 用户正在查看位于换货链中间的资产 - **WHEN** 解析响应同时包含非空的 `previous_asset` 和 `next_asset` - **THEN** 页面 MUST 同时展示“换货新资产”和“已换出旧资产”两个关联区域 +- **AND** 两个区域 MUST use their corresponding relationship item values independently #### Scenario: Hide absent exchange relationship @@ -31,9 +34,17 @@ The asset information page SHALL display exchange generation relationships exclu - **THEN** 页面 MUST NOT 展示对应的换货关联区域 - **AND** 前端 MUST NOT 根据资产状态、世代编号、换货单状态或其他字段推导该区域 +#### Scenario: Display no exchange relationship for unrelated asset + +- **GIVEN** 用户正在查看无前代和后代换货关系的资产 +- **WHEN** 解析响应未返回 `exchange_trace` 或 `exchange_trace` 内两个关联项均为空 +- **THEN** 页面 MUST NOT 展示“换货新资产”标签 +- **AND** 页面 MUST NOT 展示“已换出旧资产”标签 +- **AND** 页面 MUST NOT render an empty exchange relationship placeholder + ### Requirement: Exchange Related Asset Navigation Authorization -The asset information page SHALL allow navigation to a related exchange asset only when that relationship item has `can_view=true`. +The asset information page SHALL allow navigation to a related exchange asset only when that relationship item has `can_view=true`. When `can_view=false`, the relationship item SHALL remain visible as text only and MUST NOT provide any asset-detail navigation affordance. #### Scenario: Navigate to authorized related asset @@ -41,6 +52,7 @@ The asset information page SHALL allow navigation to a related exchange asset on - **WHEN** 用户点击关联资产标识符 - **THEN** 系统 MUST 跳转到该关联资产的资产详情 - **AND** 跳转 MUST 使用关联项返回的 `identifier` +- **AND** 页面 MUST NOT reconstruct the target identifier from the current asset or exchange order #### Scenario: Render unauthorized related asset as text @@ -49,12 +61,29 @@ The asset information page SHALL allow navigation to a related exchange asset on - **THEN** 页面 MUST 展示关联资产标识符和换货单号 - **AND** 页面 MUST NOT 将关联资产标识符渲染为可点击跳转操作 +#### Scenario: Render nullable related asset ID without navigation + +- **GIVEN** 换货关联资产项的 `asset_id=null` +- **AND** 该关联资产项的 `can_view=false` +- **WHEN** 页面渲染换货关联区域 +- **THEN** 页面 MUST 展示关联资产标识符和换货单号 +- **AND** 页面 MUST NOT depend on `asset_id` to render the relationship text +- **AND** 页面 MUST NOT provide navigation to the related asset + ### Requirement: Exchange Trace Response Contract -The frontend asset resolve contract SHALL support optional `exchange_trace.previous_asset` and `exchange_trace.next_asset` items, each with `asset_type`, `asset_id`, `identifier`, `exchange_no`, and `can_view`. +The frontend asset resolve contract SHALL support optional `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. Each non-empty relationship item SHALL preserve `asset_type:string`, `asset_id:int64|null`, `identifier:string`, `exchange_no:string`, and `can_view:bool`. #### Scenario: Read exchange trace response fields - **WHEN** 前端解析 `GET /api/admin/assets/resolve/{identifier}` 响应 - **THEN** 前端类型 MUST 支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset` - **AND** 每个非空关联项 MUST 支持读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view` +- **AND** `asset_id` MUST allow `null` for no-permission relationship items +- **AND** `previous_asset` and `next_asset` MUST each allow `null`, empty, or omitted values + +#### Scenario: Preserve asset type in exchange trace item + +- **WHEN** 解析响应返回 `exchange_trace.previous_asset` 或 `exchange_trace.next_asset` +- **THEN** 前端类型 MUST preserve `asset_type` as a string value such as `device` or `card` +- **AND** 页面 logic MUST NOT assume all related exchange assets are the same type as the current asset diff --git a/openspec/changes/update-exchange-inherited-shop-hint/proposal.md b/openspec/changes/update-exchange-inherited-shop-hint/proposal.md index dd2597d..31c8b18 100644 --- a/openspec/changes/update-exchange-inherited-shop-hint/proposal.md +++ b/openspec/changes/update-exchange-inherited-shop-hint/proposal.md @@ -7,9 +7,10 @@ ## What Changes - 在换货创建入口、发货确认入口、换货详情页补充旧资产所属店铺和新资产继承归属店铺的展示。 +- 直接换货创建入口查询新资产时,按已选旧资产的 `shop_id` 过滤新资产候选,确保新旧资产限定在同一店铺范围内选择。 - 保持现有换货创建、发货、确认完成接口和入参语义不变,不新增目标店铺选择控件或目标店铺提交字段。 - 扩展换货响应数据,返回 `inherited_shop_id` 和 `inherited_shop_name` 供前端统一展示最终归属店铺。 -- 在创建、发货、确认完成流程中补充提交中禁用、防重复提交、后端归属校验失败提示,以及成功后的详情刷新。 +- 在创建、发货、确认完成流程中补充提交中禁用、防重复提交、后端归属校验失败提示,以及成功后的详情刷新;创建换货单失败提示复用统一 HTTP 错误处理。 ## Impact diff --git a/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md b/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md index 05a5e7d..9e6eaf2 100644 --- a/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md +++ b/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md @@ -24,6 +24,23 @@ - **THEN** 系统 MUST NOT 渲染目标店铺选择控件 - **AND** 用户 MUST NOT 能够手工覆盖继承归属店铺 +#### Scenario: 直接换货新资产候选按旧资产店铺过滤 + +- **GIVEN** 用户正在创建直接换货单 +- **AND** 用户已选择旧资产 +- **WHEN** 前端加载或搜索新资产标识符候选项 +- **THEN** 新 IoT 卡候选查询 MUST include the selected old asset `shop_id` +- **AND** 新设备候选查询 MUST include the selected old asset `shop_id` +- **AND** 查询 MUST continue to include `status=1` + +#### Scenario: 未选择旧资产时不加载直接换货新资产候选 + +- **GIVEN** 用户正在创建直接换货单 +- **AND** 用户尚未选择旧资产 +- **WHEN** 新资产标识符候选项准备加载 +- **THEN** 前端 MUST NOT request new asset candidates without the old asset `shop_id` +- **AND** 新资产候选列表 MUST remain empty until an old asset is selected + ### Requirement: 换货归属继承接口契约 系统 SHALL 复用现有换货创建、发货和确认完成接口,并在换货数据模型中返回继承后的店铺标识和店铺名称,供创建结果、发货结果、完成结果和详情页统一展示。 @@ -66,14 +83,15 @@ ### Requirement: 换货归属继承错误与提交状态处理 -系统 SHALL 在新资产归属校验失败时直接展示后端错误,并在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交,同时保证失败后可以重试。 +系统 SHALL 在新资产归属校验失败时展示后端错误,并在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交,同时保证失败后可以重试。创建换货单接口失败 MUST use the unified HTTP error handling and MUST NOT render a duplicate local error alert inside the create dialog. #### Scenario: 新资产属于其他店铺时展示后端错误 - **GIVEN** 用户选择了不允许用于当前换货流程的新资产 - **WHEN** 后端返回“新资产属于其他店铺”或等价归属校验错误 -- **THEN** 系统 MUST 直接展示后端错误信息 +- **THEN** 系统 MUST 通过统一 HTTP 错误处理展示后端错误信息 - **AND** 系统 MUST NOT 提供手工修改目标店铺的替代入口 +- **AND** 创建换货单弹窗内 MUST NOT 额外展示重复错误 Alert #### Scenario: 提交期间禁用按钮防止重复提交 diff --git a/openspec/changes/update-exchange-inherited-shop-hint/tasks.md b/openspec/changes/update-exchange-inherited-shop-hint/tasks.md index b058979..8633b83 100644 --- a/openspec/changes/update-exchange-inherited-shop-hint/tasks.md +++ b/openspec/changes/update-exchange-inherited-shop-hint/tasks.md @@ -8,12 +8,13 @@ - [x] 2.1 在换货创建入口中,选择旧资产后展示旧资产所属店铺。 - [x] 2.2 在换货创建入口和发货确认入口中,选择新资产后展示只读提示 `换货完成后将归属:{店铺名称}`。 - [x] 2.3 确保上述页面不新增目标店铺选择控件,也不允许手工覆盖继承归属。 +- [x] 2.4 直接换货创建入口的新资产默认列表和搜索请求按已选旧资产 `shop_id` 过滤,并继续固定 `status=1`。 ## 3. Exchange Detail And Submission States - [x] 3.1 在换货详情页展示继承后的店铺名称,并在发货或确认完成成功后重新拉取详情。 - [x] 3.2 在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交。 -- [x] 3.3 在新资产归属校验失败时展示后端错误,并在失败后恢复可重试状态。 +- [x] 3.3 在新资产归属校验失败时通过统一 HTTP 错误处理展示后端错误,并在失败后恢复可重试状态。 ## 4. Verification diff --git a/openspec/changes/update-exchange-refund-interception-feedback/proposal.md b/openspec/changes/update-exchange-refund-interception-feedback/proposal.md index 3cfaa7c..c7d464c 100644 --- a/openspec/changes/update-exchange-refund-interception-feedback/proposal.md +++ b/openspec/changes/update-exchange-refund-interception-feedback/proposal.md @@ -7,7 +7,7 @@ ## What Changes - 复用 `POST /api/admin/exchanges` 的现有失败 `code`、`msg` 契约,不改变成功响应结构。 -- 创建换货单被退款拦截或其他后端业务规则拒绝时,在创建换货单表单顶部或旧资产行直接展示后端业务原因,例如“该资产存在退款申请”。 +- 创建换货单被退款拦截或其他后端业务规则拒绝时,通过统一 HTTP 错误处理展示后端业务原因,例如“该资产存在退款申请”,不在创建弹窗内额外渲染本地错误区域。 - 创建失败后保留当前填写的表单、已选资产与流程类型,恢复提交按钮使用户可更换资产后重新提交。 - 前端不新增退款状态查询、预检查区域或退款申请入口。 - 前端不读取或推断退款审批、撤销、拒绝或处理完成状态;是否允许换货完全依据创建接口结果。 diff --git a/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md b/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md index 9843984..b3e63c2 100644 --- a/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md +++ b/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md @@ -2,22 +2,24 @@ ### Requirement: Exchange Creation Refund Interception Feedback -The exchange creation page SHALL display the backend business reason when `POST /api/admin/exchanges` rejects the selected old asset because of an active refund application. The page MUST use the response `code` and `msg` contract without changing the successful exchange response structure. +The exchange creation page SHALL display the backend business reason through the unified HTTP error handling when `POST /api/admin/exchanges` rejects the selected old asset because of an active refund application. The page MUST use the response `code` and `msg` contract without changing the successful exchange response structure, and MUST NOT render an additional local error area inside the create dialog for the same failure. #### Scenario: Display active refund interception reason - **GIVEN** 用户已填写创建换货单表单并选择存在活跃退款申请的旧资产 - **WHEN** `POST /api/admin/exchanges` 返回失败 `code` 和 `msg` -- **THEN** 页面 MUST display the backend business reason in the create exchange form context +- **THEN** 页面 MUST display the backend business reason through the unified HTTP error handling - **AND** 当后端 `msg` 为“该资产存在退款申请”时,页面 MUST display that message directly +- **AND** 页面 MUST NOT display a duplicate error alert inside the create exchange dialog - **AND** 系统 MUST NOT create a new exchange order #### Scenario: Display other exchange creation business reason - **GIVEN** 用户已填写创建换货单表单 - **WHEN** 创建接口返回非退款相关的业务失败 `code` 和 `msg` -- **THEN** 页面 MUST display the backend `msg` in the create exchange form context +- **THEN** 页面 MUST display the backend `msg` through the unified HTTP error handling - **AND** 页面 MUST NOT replace the backend business reason with a generic frontend failure message +- **AND** 页面 MUST NOT display a duplicate error alert inside the create exchange dialog ### Requirement: Exchange Creation Retry After Interception diff --git a/openspec/changes/update-exchange-refund-interception-feedback/tasks.md b/openspec/changes/update-exchange-refund-interception-feedback/tasks.md index 460d81e..fed8e1f 100644 --- a/openspec/changes/update-exchange-refund-interception-feedback/tasks.md +++ b/openspec/changes/update-exchange-refund-interception-feedback/tasks.md @@ -5,7 +5,7 @@ ## 2. Refund Interception Feedback -- [x] 2.1 在创建换货单表单顶部或旧资产行展示后端返回的退款拦截业务原因。 +- [x] 2.1 通过统一 HTTP 错误处理展示后端返回的退款拦截业务原因,不在创建弹窗内额外渲染本地错误区域。 - [x] 2.2 退款拦截失败后保留表单输入、已选资产和流程类型。 - [x] 2.3 请求结束后恢复提交按钮,使用户能够更换资产并再次提交。 diff --git a/openspec/changes/update-package-allocation-expiry-base-override/proposal.md b/openspec/changes/update-package-allocation-expiry-base-override/proposal.md index f33f318..4b2053f 100644 --- a/openspec/changes/update-package-allocation-expiry-base-override/proposal.md +++ b/openspec/changes/update-package-allocation-expiry-base-override/proposal.md @@ -6,9 +6,10 @@ ## What Changes -- 在套餐授权/分配和已分配套餐编辑入口增加“生效条件”单选:跟随套餐默认、购买即生效、实名即生效。 +- 在套餐授权/分配和已分配套餐编辑入口增加“生效条件”单选:跟随套餐默认、购买即生效、实名激活时生效。 - 新建分配时调用 `POST /api/admin/shop-package-allocations`,明确传递 `expiry_base_override`;选择“跟随套餐默认”时 MUST 显式传递 `null`,不得省略字段。 -- 已分配套餐修改生效条件时调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,请求体为 `expiry_base_override:null|from_purchase|from_realname`。 +- 系列授权创建、系列授权新增套餐和批量套餐分配时,均 MUST 显式传递 `expiry_base_override`;该配置只作用于本次新建分配。 +- 已分配套餐修改生效条件时调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,请求体为 `expiry_base_override:null|from_purchase|from_activation`。 - 在分配详情、编辑弹框和代理系列授权套餐列表展示后端返回的默认生效条件、覆盖生效条件和最终生效条件的中文名称。 - 修改弹框提示“仅影响后续新订单,不影响已购买套餐”。 - 前端不根据默认值和覆盖值自行计算最终生效条件,直接使用后端返回的 `effective_expiry_base` 及其名称字段。 @@ -27,6 +28,9 @@ - API contracts: - `POST /api/admin/shop-package-allocations` - `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` + - `POST /api/admin/shop-series-grants` + - `PUT /api/admin/shop-series-grants/{id}/packages` + - `POST /api/admin/shop-package-batch-allocations` - Out of scope: - 已购买套餐或历史订单的生效条件迁移 - 前端根据分配字段推导或覆盖实际生效条件 diff --git a/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md index aa1406e..999365a 100644 --- a/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md +++ b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md @@ -2,7 +2,7 @@ ### Requirement: Package Allocation Expiry Base Override Contract -The shop package allocation contract SHALL support an explicit `expiry_base_override` when creating or updating an allocation. The allowed request values MUST be `null`, `from_purchase`, and `from_realname`. +The shop package allocation contract SHALL support an explicit `expiry_base_override` when creating or updating an allocation. The allowed request values MUST be `null`, `from_purchase`, and `from_activation`. The frontend MUST NOT send `from_realname`. #### Scenario: Create allocation following the package default @@ -18,11 +18,11 @@ The shop package allocation contract SHALL support an explicit `expiry_base_over - **WHEN** 用户创建店铺套餐分配 - **THEN** 请求 MUST contain `expiry_base_override: "from_purchase"` -#### Scenario: Create allocation with realname activation override +#### Scenario: Create allocation with activation override -- **GIVEN** 用户在套餐授权/分配弹框选择“实名即生效” +- **GIVEN** 用户在套餐授权/分配弹框选择“实名激活时生效” - **WHEN** 用户创建店铺套餐分配 -- **THEN** 请求 MUST contain `expiry_base_override: "from_realname"` +- **THEN** 请求 MUST contain `expiry_base_override: "from_activation"` ### Requirement: Package Allocation Expiry Base Update @@ -31,7 +31,7 @@ The system SHALL update an existing package allocation's expiry-base override th #### Scenario: Update allocation expiry base override - **GIVEN** 用户正在编辑已分配套餐的生效条件 -- **WHEN** 用户选择“购买即生效”或“实名即生效”并保存 +- **WHEN** 用户选择“购买即生效”或“实名激活时生效”并保存 - **THEN** 系统 MUST call `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` - **AND** 请求体 MUST contain the selected `expiry_base_override` @@ -41,6 +41,13 @@ The system SHALL update an existing package allocation's expiry-base override th - **WHEN** 用户选择“跟随套餐默认”并保存 - **THEN** 请求体 MUST explicitly contain `expiry_base_override: null` +#### Scenario: Use activation override enum + +- **GIVEN** 用户在已分配套餐编辑弹框选择“实名激活时生效” +- **WHEN** 用户保存生效条件 +- **THEN** 请求体 MUST contain `expiry_base_override: "from_activation"` +- **AND** 请求体 MUST NOT contain `expiry_base_override: "from_realname"` + ### Requirement: Package Allocation Expiry Base Resolution Response The package allocation response SHALL provide `default_expiry_base`, `expiry_base_override`, and `effective_expiry_base`, together with their corresponding Chinese name fields. The frontend MUST render the returned effective value and name without calculating it from other fields. @@ -59,6 +66,13 @@ The package allocation response SHALL provide `default_expiry_base`, `expiry_bas - **THEN** 页面 MUST display the backend returned `effective_expiry_base_name` - **AND** 前端 MUST NOT calculate or replace the effective value from the default value and override value +#### Scenario: Display null override as following package default + +- **GIVEN** 分配响应的 `expiry_base_override` 为 `null` +- **WHEN** 页面展示覆盖生效条件 +- **THEN** 页面 MUST use `expiry_base_override_name` when it is returned +- **AND** 页面 MUST display “跟随套餐默认” when the returned override name is empty or null + ### Requirement: Package Allocation Expiry Base Applies Prospectively The package allocation expiry-base override SHALL apply only to future new orders. The allocation creation and editing UI MUST inform the user that purchased packages are unaffected. @@ -72,3 +86,19 @@ The package allocation expiry-base override SHALL apply only to future new order - **WHEN** 用户创建、更新或恢复套餐分配的生效条件 - **THEN** 前端 MUST NOT attempt to update existing purchased packages or historical orders + +### Requirement: Package Allocation Response Field Contract + +All package allocation and package response objects involved in allocation management SHALL preserve the six expiry-base fields returned by the backend: `default_expiry_base`, `default_expiry_base_name`, `expiry_base_override`, `expiry_base_override_name`, `effective_expiry_base`, and `effective_expiry_base_name`. + +#### Scenario: Preserve six expiry-base fields in allocation responses + +- **WHEN** 前端接收套餐分配创建、更新、详情或列表响应 +- **THEN** 前端类型 MUST support all six expiry-base fields +- **AND** 页面 MUST use backend-provided `*_name` fields for display + +#### Scenario: Preserve six expiry-base fields in package responses + +- **WHEN** 前端接收套餐列表、套餐详情、套餐创建或套餐更新响应 +- **THEN** 前端类型 MUST support all six expiry-base fields when returned by the API +- **AND** 前端 MUST NOT infer allocation effective expiry behavior from package default fields diff --git a/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md index c0c5d91..397b3ca 100644 --- a/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md +++ b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md @@ -19,7 +19,7 @@ The series grant package list and package edit context SHALL display the package ### Requirement: Series Grant Package Expiry Base Editing -The series grant package edit context SHALL provide `跟随套餐默认`, `购买即生效`, and `实名即生效` options, then update the corresponding package allocation record through the package allocation expiry-base API. +The series grant package edit context SHALL provide `跟随套餐默认`, `购买即生效`, and `实名激活时生效` options, then update the corresponding package allocation record through the package allocation expiry-base API. #### Scenario: Edit override from series grant package list @@ -28,6 +28,44 @@ The series grant package edit context SHALL provide `跟随套餐默认`, `购 - **THEN** 系统 MUST use the corresponding package allocation record ID to call `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` - **AND** 选择“跟随套餐默认”时请求 MUST explicitly contain `expiry_base_override: null` +### Requirement: Series Grant Creation Expiry Base Contract + +The series grant creation and package addition flows SHALL explicitly send one shared `expiry_base_override` for all newly created package allocations. The allowed values MUST be `null`, `from_purchase`, and `from_activation`. + +#### Scenario: Create series grant with shared expiry base override + +- **WHEN** 前端调用 `POST /api/admin/shop-series-grants` +- **THEN** 请求 MUST explicitly contain `expiry_base_override` +- **AND** the selected value MUST apply to all package allocations created by the initial authorization + +#### Scenario: Add packages to existing series grant with shared expiry base override + +- **WHEN** 前端调用 `PUT /api/admin/shop-series-grants/{id}/packages` to add packages +- **THEN** 请求 MUST explicitly contain `expiry_base_override` +- **AND** the selected value MUST apply only to the newly added package allocations +- **AND** modifying or deleting existing packages MUST NOT change their historical override values + +#### Scenario: Restore default during series grant creation or package addition + +- **WHEN** 用户选择“跟随套餐默认” +- **THEN** 请求 MUST explicitly contain `expiry_base_override: null` +- **AND** 请求 MUST NOT omit the field to represent the default + +### Requirement: Batch Package Allocation Expiry Base Contract + +The batch package allocation flow SHALL apply one explicitly selected `expiry_base_override` to all allocations created by the batch request. + +#### Scenario: Create batch allocations with shared expiry base + +- **WHEN** 前端调用 `POST /api/admin/shop-package-batch-allocations` +- **THEN** 请求 MUST explicitly contain `expiry_base_override` +- **AND** all returned `data.allocations[]` items MUST preserve the allocation ID and six expiry-base fields + +#### Scenario: Batch allocation follows package default + +- **WHEN** 用户在批量分配中选择“跟随套餐默认” +- **THEN** 请求 MUST explicitly contain `expiry_base_override: null` + #### Scenario: Refresh effective value after editing - **WHEN** 代理系列授权套餐的生效条件更新成功 diff --git a/openspec/changes/update-package-allocation-expiry-base-override/tasks.md b/openspec/changes/update-package-allocation-expiry-base-override/tasks.md index 424d5f0..c22694d 100644 --- a/openspec/changes/update-package-allocation-expiry-base-override/tasks.md +++ b/openspec/changes/update-package-allocation-expiry-base-override/tasks.md @@ -6,7 +6,7 @@ ## 2. Allocation Expiry Condition UI -- [x] 2.1 在套餐授权/分配弹框提供“跟随套餐默认”“购买即生效”“实名即生效”三选一。 +- [x] 2.1 在套餐授权/分配弹框提供“跟随套餐默认”“购买即生效”“实名激活时生效”三选一。 - [x] 2.2 选择“跟随套餐默认”时显式提交 `expiry_base_override: null`。 - [x] 2.3 在已分配套餐编辑弹框显示“仅影响后续新订单,不影响已购买套餐”提示。 - [x] 2.4 在已分配套餐详情或编辑弹框显示默认值、覆盖值和最终值的后端中文名称。 @@ -20,7 +20,7 @@ ## 4. Policy Integrity and Verification - [x] 4.1 前端不从默认值和覆盖值计算或修改 `effective_expiry_base`。 -- [x] 4.2 验证新建分配时三种选项分别提交 `null`、`from_purchase`、`from_realname`。 +- [x] 4.2 验证新建分配时三种选项分别提交 `null`、`from_purchase`、`from_activation`,且不提交 `from_realname`。 - [x] 4.3 验证编辑覆盖值和恢复默认均只影响后续新订单,不影响已购买套餐。 - [x] 4.4 验证页面可区分默认值、覆盖值和最终生效条件,并正确显示后端中文名称。 - [x] 4.5 运行相关前端校验,并执行 `openspec validate update-package-allocation-expiry-base-override --strict`。 diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index 3883087..a9b56a3 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -77,7 +77,7 @@ export interface BatchUpdateAssetRealnamePolicyRequest { // 换货关联资产 export interface AssetExchangeTraceAsset { - asset_type: AssetType + asset_type: string asset_id: number | null identifier: string exchange_no: string diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 557b80f..83dda5f 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -787,6 +787,7 @@ (e: 'updateBindingCardRealnameStatus', payload: { card: BindingCard }): void (e: 'navigateToCard', iccid: string): void (e: 'navigateToDevice', deviceNo: string): void + (e: 'navigateToAsset', identifier: string): void (e: 'viewSyncTrail'): void } @@ -868,13 +869,7 @@ const handleNavigateToExchangeAsset = (asset: AssetExchangeTraceAsset) => { if (!asset.can_view) return - - if (asset.asset_type === 'card') { - emit('navigateToCard', asset.identifier) - return - } - - emit('navigateToDevice', asset.identifier) + emit('navigateToAsset', asset.identifier) } // 复制ICCID diff --git a/src/views/asset-management/asset-information/index.vue b/src/views/asset-management/asset-information/index.vue index 6c503a3..eb0c5a5 100644 --- a/src/views/asset-management/asset-information/index.vue +++ b/src/views/asset-management/asset-information/index.vue @@ -41,6 +41,7 @@ @enable-binding-card="handleEnableBindingCard" @disable-binding-card="handleDisableBindingCard" @update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus" + @navigate-to-asset="handleNavigateToAsset" @navigate-to-card="handleNavigateToCard" @navigate-to-device="handleNavigateToDevice" @view-sync-trail="handleViewSyncTrail" @@ -748,6 +749,13 @@ await handleSearch({ identifier: iccid }) } + /** + * 跳转到任意资产信息 + */ + const handleNavigateToAsset = async (identifier: string) => { + await handleSearch({ identifier }) + } + /** * 跳转到绑定设备信息 */ diff --git a/src/views/asset-management/exchange-management/index.vue b/src/views/asset-management/exchange-management/index.vue index 597ad96..a9d32dc 100644 --- a/src/views/asset-management/exchange-management/index.vue +++ b/src/views/asset-management/exchange-management/index.vue @@ -50,14 +50,6 @@ @closed="handleCloseCreateDialog" > - @@ -227,6 +222,7 @@ placeholder="请输入虚拟号或IMEI搜索" :remote-method="searchNewDevices" :loading="newDeviceSearchLoading" + no-data-text="暂无同店铺可用的新资产" style="width: 100%" clearable > @@ -298,6 +294,7 @@ placeholder="请输入ICCID搜索" :remote-method="searchShipNewIotCards" :loading="newCardSearchLoading" + no-data-text="暂无可用的新资产" style="width: 100%" clearable > @@ -329,6 +326,7 @@ placeholder="请输入虚拟号或IMEI搜索" :remote-method="searchShipNewDevices" :loading="newDeviceSearchLoading" + no-data-text="暂无可用的新资产" style="width: 100%" clearable > @@ -384,7 +382,7 @@ import { ExchangeService, CardService, DeviceService } from '@/api/modules' import type { ExchangeResponse } from '@/api/modules/exchange' import type { StandaloneIotCard, Device } from '@/types/api' - import { ElAlert, ElMessage, ElTag, ElButton, ElMessageBox, ElSwitch } from 'element-plus' + import { ElMessage, ElTag, ElButton, ElMessageBox, ElSwitch } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus' import type { SearchFormItem } from '@/types' import { useCheckedColumns } from '@/composables/useCheckedColumns' @@ -409,7 +407,6 @@ const shipFormRef = ref() const createDialogVisible = ref(false) const createLoading = ref(false) - const createFailureMessage = ref('') const tableRef = ref() const createFormRef = ref() @@ -519,8 +516,7 @@ const getShopDisplayName = (option?: ShopSelectableOption | null) => { if (!option) return '' if (option.shop_name) return option.shop_name - if (option.shop_id === null || option.shop_id === 0) return '平台' - return '' + return '库存' } const createSelectedOldAsset = computed(() => { @@ -582,6 +578,13 @@ const createOldAssetShopDisplay = computed(() => getShopDisplayName(createSelectedOldAsset.value)) + const getCreateOldAssetShopId = () => { + const shopId = createSelectedOldAsset.value?.shop_id + if (typeof shopId === 'number') return shopId + if (shopId === null) return 0 + return undefined + } + const createDirectInheritedShopHint = computed(() => { const shopName = getShopDisplayName(createSelectedNewAsset.value) return shopName ? `换货完成后将归属:${shopName}` : '' @@ -895,7 +898,6 @@ // 显示创建对话框 const showCreateDialog = () => { - createFailureMessage.value = '' createDialogVisible.value = true } @@ -908,7 +910,6 @@ // 流程类型变更 const handleFlowTypeChange = async () => { - createFailureMessage.value = '' // 清空新资产标识符和数据迁移选项 createForm.new_identifier = '' createForm.migrate_data = false @@ -928,7 +929,6 @@ // 旧资产类型变更 const handleOldAssetTypeChange = async () => { - createFailureMessage.value = '' // 清空旧资产标识符 createForm.old_identifier = '' oldIotCardOptions.value = [] @@ -945,20 +945,32 @@ if (createForm.old_asset_type === 'iot_card') { await loadDefaultIotCards() // 如果是直接换货,也加载新资产列表 - if (createForm.flow_type === 'direct') { + if (createForm.flow_type === 'direct' && createForm.old_identifier) { await loadDefaultNewIotCards() } } else if (createForm.old_asset_type === 'device') { await loadDefaultDevices() // 如果是直接换货,也加载新资产列表 - if (createForm.flow_type === 'direct') { + if (createForm.flow_type === 'direct' && createForm.old_identifier) { await loadDefaultNewDevices() } } } - const handleOldIdentifierChange = () => { - createFailureMessage.value = '' + const handleOldIdentifierChange = async () => { + createFormRef.value?.clearValidate('old_identifier') + createForm.new_identifier = '' + newIotCardOptions.value = [] + newDeviceOptions.value = [] + createFormRef.value?.clearValidate('new_identifier') + + if (createForm.flow_type !== 'direct' || !createForm.old_identifier) return + + if (createForm.old_asset_type === 'iot_card') { + await loadDefaultNewIotCards() + } else if (createForm.old_asset_type === 'device') { + await loadDefaultNewDevices() + } } const refreshExchangeDetail = async (id: number) => { @@ -980,6 +992,7 @@ try { const res = await CardService.getStandaloneIotCards({ is_standalone: true, + status: 1, page: 1, page_size: 10 }) @@ -1002,6 +1015,7 @@ oldDeviceSearchLoading.value = true try { const res = await DeviceService.getDevices({ + status: 1, page: 1, page_size: 10 }) @@ -1031,6 +1045,7 @@ try { const res = await CardService.getStandaloneIotCards({ is_standalone: true, + status: 1, keyword: query, page: 1, page_size: 20 @@ -1060,6 +1075,7 @@ oldDeviceSearchLoading.value = true try { const res = await DeviceService.getDevices({ + status: 1, keyword: query, page: 1, page_size: 20 @@ -1086,10 +1102,19 @@ return } + const shopId = getCreateOldAssetShopId() + if (shopId === undefined) { + newIotCardOptions.value = [] + ElMessage.warning('请先选择旧资产') + return + } + newCardSearchLoading.value = true try { const res = await CardService.getStandaloneIotCards({ is_standalone: true, + status: 1, + shop_id: shopId, keyword: query, page: 1, page_size: 20 @@ -1139,10 +1164,18 @@ // 加载默认新IoT卡列表 const loadDefaultNewIotCards = async () => { + const shopId = getCreateOldAssetShopId() + if (shopId === undefined) { + newIotCardOptions.value = [] + return + } + newCardSearchLoading.value = true try { const res = await CardService.getStandaloneIotCards({ is_standalone: true, + status: 1, + shop_id: shopId, page: 1, page_size: 10 }) @@ -1185,9 +1218,18 @@ return } + const shopId = getCreateOldAssetShopId() + if (shopId === undefined) { + newDeviceOptions.value = [] + ElMessage.warning('请先选择旧资产') + return + } + newDeviceSearchLoading.value = true try { const res = await DeviceService.getDevices({ + status: 1, + shop_id: shopId, keyword: query, page: 1, page_size: 20 @@ -1236,9 +1278,17 @@ // 加载默认新设备列表 const loadDefaultNewDevices = async () => { + const shopId = getCreateOldAssetShopId() + if (shopId === undefined) { + newDeviceOptions.value = [] + return + } + newDeviceSearchLoading.value = true try { const res = await DeviceService.getDevices({ + status: 1, + shop_id: shopId, page: 1, page_size: 10 }) @@ -1277,7 +1327,6 @@ createFormRef.value?.validate(async (valid) => { if (!valid || createLoading.value) return - createFailureMessage.value = '' createLoading.value = true try { const exchangeReason = @@ -1299,9 +1348,7 @@ requestData.migrate_data = createForm.migrate_data } - const res = await ExchangeService.createExchange(requestData, { - requestOptions: { errorMessageMode: 'none' } - }) + const res = await ExchangeService.createExchange(requestData) if (res.code === 0) { ElMessage.success('换货单创建成功') createDialogVisible.value = false @@ -1309,9 +1356,9 @@ return } - createFailureMessage.value = res.msg || '换货单创建失败' + ElMessage.error(res.msg || '换货单创建失败') } catch (error: any) { - createFailureMessage.value = error?.response?.data?.msg || '换货单创建失败' + console.error('创建换货单失败:', error) } finally { createLoading.value = false } @@ -1320,7 +1367,6 @@ // 关闭创建对话框 const handleCloseCreateDialog = () => { - createFailureMessage.value = '' createFormRef.value?.resetFields() // 清空搜索选项 oldIotCardOptions.value = []