diff --git a/.gitignore b/.gitignore index d0a3496..5cd0646 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ docs !docs/ docs/* !docs/权限.md +!docs/api/ +docs/api/* +!docs/api/package-silence.openapi.json !docs/2026-10-26-轮询账户限流-后台接入交付.md dist-ssr *.local diff --git a/docs/api/package-silence.openapi.json b/docs/api/package-silence.openapi.json new file mode 100644 index 0000000..1ab8aed --- /dev/null +++ b/docs/api/package-silence.openapi.json @@ -0,0 +1,4390 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "One Pipe package silence admin API - Apifox snapshot", + "version": "2026-10-28", + "description": "21 order-silence and first-purchase operations. Read-only snapshot; refresh the source export when Apifox changes." + }, + "paths": { + "/api/admin/assets/{asset_type}/{id}/first-purchase-records": { + "get": { + "summary": "资产首次购买套餐记录", + "deprecated": false, + "description": "按资产查看各世代首次购买套餐记录(首次购买套餐时间、来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因);换货后的新资产在当前世代真正购买套餐后才有记录;只读。", + "tags": [ + "卡管/资产 - 首次购买套餐时间", + "资产 - 首次购买套餐时间" + ], + "parameters": [ + { + "name": "asset_type", + "in": "path", + "description": "资产类型 card/iot_card/device", + "required": true, + "example": "", + "schema": { + "description": "资产类型 card/iot_card/device", + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "资产 ID", + "required": true, + "example": 0, + "schema": { + "description": "资产 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "items": { + "$ref": "#/components/schemas/DtoAssetFirstPurchaseRecordResponse" + }, + "type": "array" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/batches": { + "get": { + "summary": "延期批次列表", + "deprecated": false, + "description": "", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "description": "页码", + "required": false, + "example": 0, + "schema": { + "description": "页码", + "type": "integer" + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量", + "required": false, + "example": 0, + "schema": { + "description": "每页数量", + "type": "integer" + } + }, + { + "name": "batch_no", + "in": "query", + "description": "批次号", + "required": false, + "example": "", + "schema": { + "description": "批次号", + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "路径", + "required": false, + "example": "", + "schema": { + "description": "路径", + "type": "string" + } + }, + { + "name": "extension_type", + "in": "query", + "description": "延期类型", + "required": false, + "example": "", + "schema": { + "description": "延期类型", + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "批次状态", + "required": false, + "example": "", + "schema": { + "description": "批次状态", + "type": "string" + } + }, + { + "name": "has_exception", + "in": "query", + "description": "只看含异常明细的批次", + "required": false, + "example": "", + "schema": { + "description": "只看含异常明细的批次", + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceBatchPageResult" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/batches/{id}": { + "get": { + "summary": "延期批次详情(含明细)", + "deprecated": false, + "description": "", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID", + "required": true, + "example": 0, + "schema": { + "description": "ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceBatchResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/eligibility": { + "get": { + "summary": "延期资格", + "deprecated": false, + "description": "待实名但已到期的先判定为过期;返回最少 / 最多天数与月数、单价、各月数档位;金额全部由后端计算。\n\n沉默期总开关关闭时返回错误码 1274(silence_feature_disabled);已发生的支付回调、查单与审批回调不受开关影响。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "package_usage_id", + "in": "query", + "description": "套餐记录 ID", + "required": true, + "example": 0, + "schema": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceEligibilityResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/extensions/{id}/handle": { + "post": { + "summary": "处理异常延期明细", + "deprecated": false, + "description": "需 package_silence:exception 权限;retry 按原快照重新执行,close 关闭(在线订单按冻结商户原路退款)。不得直接改截止时间。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "延期明细 ID", + "required": true, + "example": 0, + "schema": { + "description": "延期明细 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilenceExceptionHandleParams" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceExtensionResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/orders": { + "get": { + "summary": "延期订单列表", + "deprecated": false, + "description": "叠加店铺数据范围;支付方式、收款商户、交易流水号取已支付或已退款支付单快照。导出使用统一导出任务,场景 package_silence_order,筛选参数同本接口。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "description": "页码", + "required": false, + "example": 0, + "schema": { + "description": "页码", + "type": "integer" + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量", + "required": false, + "example": 0, + "schema": { + "description": "每页数量", + "type": "integer" + } + }, + { + "name": "package_usage_id", + "in": "query", + "description": "套餐记录 ID", + "required": false, + "example": 0, + "schema": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer", + "nullable": true + } + }, + { + "name": "package_id", + "in": "query", + "description": "套餐 ID", + "required": false, + "example": 0, + "schema": { + "description": "套餐 ID", + "minimum": 0, + "type": "integer", + "nullable": true + } + }, + { + "name": "asset_identifier", + "in": "query", + "description": "资产标识(ICCID / 虚拟号 / IMEI)", + "required": false, + "example": "", + "schema": { + "description": "资产标识(ICCID / 虚拟号 / IMEI)", + "type": "string" + } + }, + { + "name": "order_no", + "in": "query", + "description": "订单号", + "required": false, + "example": "", + "schema": { + "description": "订单号", + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "状态", + "required": false, + "example": "", + "schema": { + "description": "状态", + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "description": "来源", + "required": false, + "example": "", + "schema": { + "description": "来源", + "type": "string" + } + }, + { + "name": "payment_method", + "in": "query", + "description": "支付方式", + "required": false, + "example": "", + "schema": { + "description": "支付方式", + "type": "string" + } + }, + { + "name": "merchant_name", + "in": "query", + "description": "收款商户名称(模糊)", + "required": false, + "example": "", + "schema": { + "description": "收款商户名称(模糊)", + "type": "string" + } + }, + { + "name": "third_party_trade_no", + "in": "query", + "description": "交易流水号或线下交易单号", + "required": false, + "example": "", + "schema": { + "description": "交易流水号或线下交易单号", + "type": "string" + } + }, + { + "name": "paid_start", + "in": "query", + "description": "支付时间起(RFC3339)", + "required": false, + "example": "", + "schema": { + "description": "支付时间起(RFC3339)", + "type": "string" + } + }, + { + "name": "paid_end", + "in": "query", + "description": "支付时间止(RFC3339)", + "required": false, + "example": "", + "schema": { + "description": "支付时间止(RFC3339)", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceOrderPageResult" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/orders/{id}": { + "get": { + "summary": "延期订单详情", + "deprecated": false, + "description": "", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID", + "required": true, + "example": 0, + "schema": { + "description": "ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceExtensionOrderResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/orders/offline": { + "post": { + "summary": "登记线下付费延期", + "deprecated": false, + "description": "需 package_silence:offline 权限;实收金额必须等于系统计算金额(1273),同一收款方式下交易单号唯一(1272);政策未勾选 offline_paid 免审批时提交企微审批,通过后延期,驳回订单 rejected 且系统不退款。\n\n沉默期总开关关闭时返回错误码 1274(silence_feature_disabled);已发生的支付回调、查单与审批回调不受开关影响。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilenceOfflineOrderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceExtensionOrderResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/orders/qrcode": { + "post": { + "summary": "运营创建在线延期订单并生成支付二维码", + "deprecated": false, + "description": "需 package_silence:extend 权限;商户按支付方式经商户池路由并冻结;返回 qr_content 由前端渲染为二维码,客户扫码支付后自动延期,不审批。同一套餐已有待支付订单时先查单:未支付则关闭旧单再新建,已支付则按已支付处理并返回旧单。\n\n沉默期总开关关闭时返回错误码 1274(silence_feature_disabled);已发生的支付回调、查单与审批回调不受开关影响。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilenceOnlineOrderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceExtensionOrderResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/quote": { + "get": { + "summary": "按天数试算付费延期", + "deprecated": false, + "description": "按每月 30 天向上取整计费(1 天按 30 天,31 天按 60 天),延长天数等于计费天数。\n\n沉默期总开关关闭时返回错误码 1274(silence_feature_disabled);已发生的支付回调、查单与审批回调不受开关影响。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [ + { + "name": "package_usage_id", + "in": "query", + "description": "套餐记录 ID", + "required": true, + "example": 0, + "schema": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + } + }, + { + "name": "days", + "in": "query", + "description": "输入的延期天数(按每月 30 天向上取整计费)", + "required": true, + "example": 0, + "schema": { + "description": "输入的延期天数(按每月 30 天向上取整计费)", + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceQuoteResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/special": { + "post": { + "summary": "提交特殊延期(单个 / 批量)", + "deprecated": false, + "description": "需 package_silence:extend 权限,批量(多条或导入文件)另需 package_silence:extend_batch;免费延期受政策次数约束(1267);不受付费上限与套餐是否支持延期约束,只要求新截止晚于今天。政策未勾选对应免审批场景(批量看 batch)时提交企微审批,通过后逐条执行。\n\n沉默期总开关关闭时返回错误码 1274(silence_feature_disabled);已发生的支付回调、查单与审批回调不受开关影响。", + "tags": [ + "卡管/沉默期 - 延期", + "沉默期 - 延期" + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilenceSpecialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceBatchResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/policies": { + "get": { + "summary": "延期政策列表", + "deprecated": false, + "description": "需 package_silence:policy 权限。", + "tags": [ + "卡管/沉默期 - 延期政策", + "沉默期 - 延期政策" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "description": "页码", + "required": false, + "example": 0, + "schema": { + "description": "页码", + "type": "integer" + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量", + "required": false, + "example": 0, + "schema": { + "description": "每页数量", + "type": "integer" + } + }, + { + "name": "name", + "in": "query", + "description": "政策名称(模糊)", + "required": false, + "example": "", + "schema": { + "description": "政策名称(模糊)", + "type": "string" + } + }, + { + "name": "scope_type", + "in": "query", + "description": "适用范围", + "required": false, + "example": "", + "schema": { + "description": "适用范围", + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "状态", + "required": false, + "example": 0, + "schema": { + "description": "状态", + "type": "integer", + "nullable": true + } + }, + { + "name": "effective_at", + "in": "query", + "description": "在该时间生效(RFC3339)", + "required": false, + "example": "", + "schema": { + "description": "在该时间生效(RFC3339)", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "items": { + "$ref": "#/components/schemas/DtoSilencePolicyResponse" + }, + "type": "array" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "summary": "新增延期政策", + "deprecated": false, + "description": "同一优先级的适用范围与生效时间段不能重叠,重叠返回 1269(silence_policy_overlap)。修改只影响之后发起的延期。", + "tags": [ + "卡管/沉默期 - 延期政策", + "沉默期 - 延期政策" + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilencePolicyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilencePolicyResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/policies/{id}": { + "get": { + "summary": "延期政策详情", + "deprecated": false, + "description": "", + "tags": [ + "卡管/沉默期 - 延期政策", + "沉默期 - 延期政策" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID", + "required": true, + "example": 0, + "schema": { + "description": "ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilencePolicyResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "put": { + "summary": "编辑延期政策", + "deprecated": false, + "description": "全量提交字段;已创建的延期订单与已提交的审批按快照执行。", + "tags": [ + "卡管/沉默期 - 延期政策", + "沉默期 - 延期政策" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "政策 ID", + "required": true, + "example": 0, + "schema": { + "description": "政策 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilencePolicyUpdateParams" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilencePolicyResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/policies/{id}/status": { + "put": { + "summary": "启停延期政策", + "deprecated": false, + "description": "启用时同样做重叠校验。", + "tags": [ + "卡管/沉默期 - 延期政策", + "沉默期 - 延期政策" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "政策 ID", + "required": true, + "example": 0, + "schema": { + "description": "政策 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilencePolicyStatusParams" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilencePolicyResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/policies/match": { + "get": { + "summary": "查询套餐当前匹配的延期政策", + "deprecated": false, + "description": "优先级:指定套餐 > 套餐系列 > 全部套餐;只匹配启用且在生效期内的政策。", + "tags": [ + "卡管/沉默期 - 延期政策", + "沉默期 - 延期政策" + ], + "parameters": [ + { + "name": "package_id", + "in": "query", + "description": "套餐 ID", + "required": true, + "example": 0, + "schema": { + "description": "套餐 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilencePolicyMatchResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/records": { + "get": { + "summary": "套餐沉默期流水", + "deprecated": false, + "description": "设置、调整、过期、结束、延期、换货迁移、回填等不可变流水。", + "tags": [ + "卡管/沉默期", + "沉默期" + ], + "parameters": [ + { + "name": "package_usage_id", + "in": "query", + "description": "套餐记录 ID", + "required": true, + "example": 0, + "schema": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "items": { + "$ref": "#/components/schemas/DtoSilenceRecordResponse" + }, + "type": "array" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/switch": { + "get": { + "summary": "查询沉默期总开关", + "deprecated": false, + "description": "返回总开关当前值与打开前 4 项检查结果(未配置默认沉默期的套餐、未回填记录、全部套餐延期政策、企微审批场景)。", + "tags": [ + "卡管/沉默期", + "沉默期" + ], + "parameters": [], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceSwitchResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "put": { + "summary": "切换沉默期总开关(仅超级管理员)", + "deprecated": false, + "description": "打开前逐项检查,任一项不满足返回 1275(silence_switch_precheck_failed),data.failed_items 为未满足项与数量;关闭(应急)不检查:规则暂停、数据保留。每次切换写审计 package.silence_switch_changed。", + "tags": [ + "卡管/沉默期", + "沉默期" + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DtoSilenceSwitchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceSwitchResponse" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/api/admin/package-silence/usages": { + "get": { + "summary": "即将过期 / 已过期套餐", + "deprecated": false, + "description": "view=expiring(默认 7 天内到期)/ expired / all;导出使用统一导出任务,场景 package_silence_usage,筛选参数同本接口。", + "tags": [ + "卡管/沉默期", + "沉默期" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "description": "页码", + "required": false, + "example": 0, + "schema": { + "description": "页码", + "type": "integer" + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量", + "required": false, + "example": 0, + "schema": { + "description": "每页数量", + "type": "integer" + } + }, + { + "name": "view", + "in": "query", + "description": "视图:expiring 即将过期(默认 7 天内) / expired 已过期 / all 全部待实名与已过期", + "required": false, + "example": "", + "schema": { + "description": "视图:expiring 即将过期(默认 7 天内) / expired 已过期 / all 全部待实名与已过期", + "type": "string" + } + }, + { + "name": "within_days", + "in": "query", + "description": "即将过期的天数范围(默认 7)", + "required": false, + "example": 0, + "schema": { + "description": "即将过期的天数范围(默认 7)", + "type": "integer" + } + }, + { + "name": "asset_identifier", + "in": "query", + "description": "资产标识", + "required": false, + "example": "", + "schema": { + "description": "资产标识", + "type": "string" + } + }, + { + "name": "package_id", + "in": "query", + "description": "套餐 ID", + "required": false, + "example": 0, + "schema": { + "description": "套餐 ID", + "minimum": 0, + "type": "integer", + "nullable": true + } + }, + { + "name": "shop_id", + "in": "query", + "description": "资产所属店铺", + "required": false, + "example": 0, + "schema": { + "description": "资产所属店铺", + "minimum": 0, + "type": "integer", + "nullable": true + } + }, + { + "name": "deadline_start", + "in": "query", + "description": "截止时间起(RFC3339)", + "required": false, + "example": "", + "schema": { + "description": "截止时间起(RFC3339)", + "type": "string" + } + }, + { + "name": "deadline_end", + "in": "query", + "description": "截止时间止(RFC3339)", + "required": false, + "example": "", + "schema": { + "description": "截止时间止(RFC3339)", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "响应码", + "type": "integer", + "examples": [ + 0 + ] + }, + "data": { + "$ref": "#/components/schemas/DtoSilenceUsagePageResult" + }, + "msg": { + "description": "响应消息", + "type": "string", + "examples": [ + "success" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "data", + "msg", + "timestamp" + ], + "type": "object" + } + } + }, + "headers": {} + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "401": { + "description": "未认证或认证已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "403": { + "description": "无权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + }, + "500": { + "description": "服务器内部错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "headers": {} + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "DtoAssetFirstPurchaseRecordResponse": { + "properties": { + "asset_id": { + "description": "资产 ID", + "minimum": 0, + "type": "integer" + }, + "asset_identifier": { + "description": "资产标识快照", + "type": "string" + }, + "asset_type": { + "description": "资产类型", + "type": "string" + }, + "created_at": { + "description": "记录时间", + "format": "date-time", + "type": "string" + }, + "end_reason": { + "description": "结束原因:exchange_return 换货回库", + "type": "string" + }, + "ended_at": { + "description": "该世代结束时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "first_purchase_at": { + "description": "首次购买套餐时间", + "format": "date-time", + "type": "string" + }, + "generation": { + "description": "世代", + "type": "integer" + }, + "id": { + "description": "记录 ID", + "minimum": 0, + "type": "integer" + }, + "order_no": { + "description": "订单号", + "type": "string" + }, + "source": { + "description": "来源:order 首次购买套餐 / backfill 历史回填", + "type": "string" + }, + "source_name": { + "description": "来源名称", + "type": "string" + } + }, + "type": "object" + }, + "ErrorResponse": { + "properties": { + "code": { + "description": "错误码", + "type": "integer", + "examples": [ + 1001 + ] + }, + "data": { + "description": "错误详情(可选)", + "type": "object", + "properties": {} + }, + "msg": { + "description": "错误消息", + "type": "string", + "examples": [ + "参数验证失败" + ] + }, + "timestamp": { + "description": "时间戳", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "code", + "msg", + "timestamp" + ], + "type": "object" + }, + "DtoSilenceBatchPageResult": { + "properties": { + "items": { + "description": "批次列表", + "items": { + "$ref": "#/components/schemas/DtoSilenceBatchResponse" + }, + "type": "array" + }, + "page": { + "description": "页码", + "type": "integer" + }, + "page_size": { + "description": "每页数量", + "type": "integer" + }, + "total": { + "description": "总数", + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilenceBatchResponse": { + "properties": { + "amount": { + "description": "费用(分)", + "type": "integer" + }, + "approval_instance_id": { + "description": "审批实例 ID", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "approval_required": { + "description": "是否需要审批", + "type": "boolean" + }, + "approval_status": { + "description": "审批状态", + "type": "string" + }, + "basis": { + "description": "依据", + "type": "string" + }, + "batch_no": { + "description": "批次号", + "type": "string" + }, + "completed_at": { + "description": "完成时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "evidence_refs": { + "description": "附件", + "items": { + "type": "string" + }, + "type": "array" + }, + "extension_type": { + "description": "延期类型", + "type": "string" + }, + "failed_count": { + "description": "失败数", + "type": "integer" + }, + "id": { + "description": "批次 ID", + "minimum": 0, + "type": "integer" + }, + "items": { + "description": "明细", + "items": { + "$ref": "#/components/schemas/DtoSilenceExtensionResponse" + }, + "type": "array" + }, + "path": { + "description": "路径:online / offline / special / adjust", + "type": "string" + }, + "reason": { + "description": "原因", + "type": "string" + }, + "requested_at": { + "description": "发起时间", + "format": "date-time", + "type": "string" + }, + "requested_by": { + "description": "发起人", + "minimum": 0, + "type": "integer" + }, + "requested_count": { + "description": "明细数", + "type": "integer" + }, + "status": { + "description": "批次状态", + "type": "string" + }, + "succeeded_count": { + "description": "成功数", + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilenceExtensionResponse": { + "properties": { + "after_deadline_at": { + "description": "延期后截止时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "amount": { + "description": "金额(分)", + "type": "integer" + }, + "asset_identifier": { + "description": "资产标识", + "type": "string" + }, + "asset_type": { + "description": "资产类型", + "type": "string" + }, + "batch_id": { + "description": "批次 ID", + "minimum": 0, + "type": "integer" + }, + "before_deadline_at": { + "description": "延期前截止时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "executed_at": { + "description": "执行时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "extension_days": { + "description": "延长天数", + "type": "integer" + }, + "extension_order_id": { + "description": "延期订单 ID", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "extension_type": { + "description": "延期类型", + "type": "string" + }, + "failure_code": { + "description": "失败 / 异常原因码", + "type": "string" + }, + "failure_summary": { + "description": "失败 / 异常说明", + "type": "string" + }, + "id": { + "description": "明细 ID", + "minimum": 0, + "type": "integer" + }, + "input_days": { + "description": "输入天数", + "type": "integer" + }, + "months": { + "description": "计费月数", + "type": "integer" + }, + "package_name": { + "description": "套餐名称", + "type": "string" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "row_no": { + "description": "行号", + "type": "integer" + }, + "status": { + "description": "明细状态", + "type": "string" + } + }, + "type": "object" + }, + "DtoSilenceEligibilityResponse": { + "properties": { + "can_extend": { + "description": "能否办理付费延期", + "type": "boolean" + }, + "extended_days": { + "description": "已累计延期天数", + "type": "integer" + }, + "extension_enabled": { + "description": "套餐是否支持付费延期", + "type": "boolean" + }, + "free_extension_available": { + "description": "是否还能申请免费延期", + "type": "boolean" + }, + "free_extension_limit": { + "description": "免费延期次数上限", + "type": "integer" + }, + "free_extension_used": { + "description": "已使用免费延期次数", + "type": "integer" + }, + "max_days": { + "description": "本次付费延期可输入的最多天数", + "type": "integer" + }, + "max_months": { + "description": "本次付费延期最多月数(特殊延期不受此限)", + "type": "integer" + }, + "min_days": { + "description": "可输入的最少天数(付费与特殊延期都适用)", + "type": "integer" + }, + "min_months": { + "description": "最少延期月数", + "type": "integer" + }, + "options": { + "description": "min~max 每档计费天数、金额与新截止日期", + "items": { + "$ref": "#/components/schemas/DtoSilenceExtensionOption" + }, + "type": "array" + }, + "original_deadline": { + "description": "当前截止日期(YYYY-MM-DD)", + "type": "string" + }, + "overdue_days": { + "description": "已过期天数", + "type": "integer" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "paid_extended_days": { + "description": "其中付费延期累计天数", + "type": "integer" + }, + "pending_order_no": { + "description": "未结束的延期订单号(有则不能新建,需先完成或关闭)", + "type": "string" + }, + "policy_id": { + "description": "当前匹配的延期政策 ID", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "policy_name": { + "description": "当前匹配的延期政策名称", + "type": "string" + }, + "reason": { + "description": "付费延期不能办理的原因:not_expired/no_silence/extension_disabled/no_policy/exceeds_limit/refunded/order_pending/activated/adjust_pending", + "type": "string" + }, + "silence_status": { + "description": "沉默期状态(待实名但已到期的先判定为过期)", + "type": "string" + }, + "special_extension_available": { + "description": "能否申请特殊延期(只要求套餐已过期)", + "type": "boolean" + }, + "unit_price": { + "description": "每月单价(分),取当前匹配的延期政策", + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilenceExtensionOption": { + "properties": { + "amount": { + "description": "金额(分)", + "type": "integer" + }, + "billed_days": { + "description": "计费 / 延长天数(月数 × 30)", + "type": "integer" + }, + "months": { + "description": "计费月数", + "type": "integer" + }, + "new_deadline": { + "description": "新截止日期(YYYY-MM-DD,当日 23:59:59 截止)", + "type": "string" + } + }, + "type": "object" + }, + "DtoSilenceExceptionHandleParams": { + "properties": { + "action": { + "description": "处理方式:retry 重试执行 / close 关闭(在线订单关闭时原路退款)", + "type": "string" + }, + "id": { + "description": "延期明细 ID", + "minimum": 0, + "type": "integer" + }, + "reason": { + "description": "处理原因", + "type": "string" + } + }, + "required": [ + "action", + "id", + "reason" + ], + "type": "object" + }, + "DtoSilenceOrderPageResult": { + "properties": { + "items": { + "description": "订单列表", + "items": { + "$ref": "#/components/schemas/DtoSilenceExtensionOrderResponse" + }, + "type": "array" + }, + "page": { + "description": "页码", + "type": "integer" + }, + "page_size": { + "description": "每页数量", + "type": "integer" + }, + "total": { + "description": "总数", + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilenceExtensionOrderResponse": { + "properties": { + "amount": { + "description": "应付金额(分)", + "type": "integer" + }, + "approval_instance_id": { + "description": "审批实例 ID", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "approval_required": { + "description": "是否需要审批", + "type": "boolean" + }, + "asset_id": { + "description": "资产 ID", + "minimum": 0, + "type": "integer" + }, + "asset_identifier": { + "description": "资产标识快照", + "type": "string" + }, + "asset_type": { + "description": "资产类型 iot_card / device", + "type": "string" + }, + "batch_no": { + "description": "批次号", + "type": "string" + }, + "before_deadline_at": { + "description": "下单时截止时间", + "format": "date-time", + "type": "string" + }, + "completed_at": { + "description": "延期生效时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "created_at": { + "description": "创建时间", + "format": "date-time", + "type": "string" + }, + "created_by": { + "description": "后台创建人", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "customer_id": { + "description": "H5 下单的客户", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "evidence_refs": { + "description": "申请依据", + "items": { + "type": "string" + }, + "type": "array" + }, + "exception_code": { + "description": "异常原因码", + "type": "string" + }, + "exception_summary": { + "description": "异常说明", + "type": "string" + }, + "expected_deadline_at": { + "description": "预计新截止时间", + "format": "date-time", + "type": "string" + }, + "expire_at": { + "description": "支付截止时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "extension_days": { + "description": "计费 / 延长天数", + "type": "integer" + }, + "id": { + "description": "订单 ID", + "minimum": 0, + "type": "integer" + }, + "input_days": { + "description": "输入天数", + "type": "integer" + }, + "months": { + "description": "计费月数", + "type": "integer" + }, + "offline_payment_method_name": { + "description": "线下收款方式", + "type": "string" + }, + "offline_trade_no": { + "description": "线下交易单号", + "type": "string" + }, + "order_no": { + "description": "订单号", + "type": "string" + }, + "package_id": { + "description": "套餐 ID", + "minimum": 0, + "type": "integer" + }, + "package_name": { + "description": "套餐名称快照", + "type": "string" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "paid_amount": { + "description": "实付金额(分)", + "type": "integer", + "nullable": true + }, + "paid_at": { + "description": "支付时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "payment_merchant_name": { + "description": "收款商户名称(取已支付或已退款支付单快照)", + "type": "string" + }, + "payment_method": { + "description": "支付方式:wechat/alipay/offline", + "type": "string" + }, + "payment_no": { + "description": "支付单号", + "type": "string" + }, + "payment_voucher_keys": { + "description": "线下收款凭证", + "items": { + "type": "string" + }, + "type": "array" + }, + "policy_name": { + "description": "政策名称快照", + "type": "string" + }, + "qr_content": { + "description": "支付内容(待支付时返回,前端渲染为二维码或跳转链接)", + "type": "string" + }, + "refund_amount": { + "description": "退款金额", + "type": "integer", + "nullable": true + }, + "refund_no": { + "description": "原路退款单号(仅执行异常)", + "type": "string" + }, + "refunded_at": { + "description": "退款时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "shop_id": { + "description": "下单时资产所属店铺", + "minimum": 0, + "type": "integer" + }, + "source": { + "description": "来源:client / admin / offline", + "type": "string" + }, + "source_name": { + "description": "来源名称", + "type": "string" + }, + "status": { + "description": "状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded", + "type": "string" + }, + "status_name": { + "description": "状态名称", + "type": "string" + }, + "third_party_trade_no": { + "description": "交易流水号", + "type": "string" + }, + "unit_price": { + "description": "单价(分/月)", + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilenceOfflineOrderRequest": { + "properties": { + "days": { + "description": "输入的延期天数", + "type": "integer" + }, + "evidence_refs": { + "description": "客户申请依据", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "offline_payment_method_id": { + "description": "线下收款方式(字典 ID)", + "minimum": 0, + "type": "integer" + }, + "offline_trade_no": { + "description": "线下交易单号(同一收款方式下唯一)", + "type": "string" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "paid_amount": { + "description": "实收金额(分),必须等于系统计算金额", + "type": "integer" + }, + "paid_at": { + "description": "付款时间", + "format": "date-time", + "type": "string" + }, + "payment_voucher_keys": { + "description": "收款凭证对象存储 Key(1~5 个)", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "request_id": { + "description": "幂等键", + "type": "string" + } + }, + "required": [ + "days", + "offline_payment_method_id", + "offline_trade_no", + "package_usage_id", + "paid_at", + "payment_voucher_keys", + "request_id" + ], + "type": "object" + }, + "DtoSilenceOnlineOrderRequest": { + "properties": { + "days": { + "description": "输入的延期天数", + "type": "integer" + }, + "evidence_refs": { + "description": "客户申请记录或截图对象存储 Key(后台创建时填写)", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "payment_method": { + "description": "支付方式:wechat / alipay(经商户池路由)", + "type": "string" + }, + "request_id": { + "description": "下单幂等键", + "type": "string" + } + }, + "required": [ + "days", + "package_usage_id", + "payment_method", + "request_id" + ], + "type": "object" + }, + "DtoSilenceQuoteResponse": { + "properties": { + "amount": { + "description": "金额(分)", + "type": "integer" + }, + "billed_days": { + "description": "计费 / 延长天数", + "type": "integer" + }, + "input_days": { + "description": "输入天数", + "type": "integer" + }, + "months": { + "description": "计费月数", + "type": "integer" + }, + "new_deadline": { + "description": "新截止日期", + "type": "string" + }, + "valid": { + "description": "是否在可办理范围内", + "type": "boolean" + } + }, + "type": "object" + }, + "DtoSilenceSpecialRequest": { + "properties": { + "amount": { + "description": "费用(分,免费为 0;优惠价为实收合计)", + "type": "integer" + }, + "basis": { + "description": "免费或优惠依据", + "type": "string" + }, + "evidence_refs": { + "description": "客户申请记录或截图", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "extension_type": { + "description": "延期类型:free 免费 / discount 优惠价 / complaint_compensation 客诉补偿 / system_compensation 系统异常补偿 / special_customer 特殊客户政策", + "type": "string" + }, + "import_file_key": { + "description": "批量导入的明细文件 Key", + "type": "string" + }, + "items": { + "description": "明细(批量需 package_silence:extend_batch 权限)", + "items": { + "$ref": "#/components/schemas/DtoSilenceSpecialItem" + }, + "type": "array", + "nullable": true + }, + "offline_paid_at": { + "description": "优惠价有收款时:付款时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "offline_payment_method_id": { + "description": "优惠价有收款时:线下收款方式", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "offline_trade_no": { + "description": "优惠价有收款时:交易单号", + "type": "string" + }, + "payment_voucher_keys": { + "description": "优惠价有收款时:收款凭证", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "reason": { + "description": "原因", + "type": "string" + } + }, + "required": [ + "extension_type", + "items", + "reason" + ], + "type": "object" + }, + "DtoSilenceSpecialItem": { + "properties": { + "days": { + "description": "延期天数(按 30 天向上取整)", + "type": "integer" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "days", + "package_usage_id" + ], + "type": "object" + }, + "DtoSilencePolicyResponse": { + "properties": { + "approval_exempt_types": { + "description": "免审批场景", + "items": { + "type": "string" + }, + "type": "array" + }, + "created_at": { + "description": "创建时间", + "format": "date-time", + "type": "string" + }, + "creator": { + "description": "创建人", + "minimum": 0, + "type": "integer" + }, + "effective_from": { + "description": "生效时间", + "format": "date-time", + "type": "string" + }, + "effective_to": { + "description": "失效时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "free_extension_limit": { + "description": "免费延期次数", + "type": "integer" + }, + "id": { + "description": "政策 ID", + "minimum": 0, + "type": "integer" + }, + "name": { + "description": "政策名称", + "type": "string" + }, + "paid_max_months": { + "description": "付费延期累计最多月数", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "scope_ids": { + "description": "系列或套餐 ID 列表", + "items": { + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "scope_type": { + "description": "适用范围", + "type": "string" + }, + "scope_type_name": { + "description": "适用范围名称", + "type": "string" + }, + "single_max_months": { + "description": "单次付费延期最多月数", + "type": "integer", + "nullable": true + }, + "status": { + "description": "状态:0-停用 1-启用", + "type": "integer" + }, + "unit_price": { + "description": "每月单价(分)", + "type": "integer" + }, + "updated_at": { + "description": "更新时间", + "format": "date-time", + "type": "string" + }, + "updater": { + "description": "更新人", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilencePolicyRequest": { + "properties": { + "approval_exempt_types": { + "description": "免审批场景(多选,空表示全部需审批)", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "effective_from": { + "description": "生效时间", + "format": "date-time", + "type": "string" + }, + "effective_to": { + "description": "失效时间(为空表示长期)", + "format": "date-time", + "type": "string", + "nullable": true + }, + "free_extension_limit": { + "description": "每笔套餐免费延期次数", + "minimum": 0, + "type": "integer" + }, + "name": { + "description": "政策名称(唯一)", + "maxLength": 100, + "type": "string" + }, + "paid_max_months": { + "description": "付费延期累计最多月数", + "minimum": 0, + "type": "integer" + }, + "remark": { + "description": "备注", + "maxLength": 500, + "type": "string" + }, + "scope_ids": { + "description": "系列 ID 或套餐 ID 列表(all 时为空)", + "items": { + "minimum": 0, + "type": "integer" + }, + "type": "array", + "nullable": true + }, + "scope_type": { + "description": "适用范围:all-全部套餐 series-套餐系列 package-指定套餐", + "type": "string" + }, + "single_max_months": { + "description": "单次付费延期最多月数(为空表示不单独限制)", + "type": "integer", + "nullable": true + }, + "status": { + "description": "状态:0-停用 1-启用(新增默认启用)", + "type": "integer", + "nullable": true + }, + "unit_price": { + "description": "每月单价(分),1 月 = 30 天", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "effective_from", + "name", + "scope_type" + ], + "type": "object" + }, + "DtoSilencePolicyUpdateParams": { + "properties": { + "approval_exempt_types": { + "description": "免审批场景(多选,空表示全部需审批)", + "items": { + "type": "string" + }, + "type": "array", + "nullable": true + }, + "effective_from": { + "description": "生效时间", + "format": "date-time", + "type": "string" + }, + "effective_to": { + "description": "失效时间(为空表示长期)", + "format": "date-time", + "type": "string", + "nullable": true + }, + "free_extension_limit": { + "description": "每笔套餐免费延期次数", + "minimum": 0, + "type": "integer" + }, + "id": { + "description": "政策 ID", + "minimum": 0, + "type": "integer" + }, + "name": { + "description": "政策名称(唯一)", + "maxLength": 100, + "type": "string" + }, + "paid_max_months": { + "description": "付费延期累计最多月数", + "minimum": 0, + "type": "integer" + }, + "remark": { + "description": "备注", + "maxLength": 500, + "type": "string" + }, + "scope_ids": { + "description": "系列 ID 或套餐 ID 列表(all 时为空)", + "items": { + "minimum": 0, + "type": "integer" + }, + "type": "array", + "nullable": true + }, + "scope_type": { + "description": "适用范围:all-全部套餐 series-套餐系列 package-指定套餐", + "type": "string" + }, + "single_max_months": { + "description": "单次付费延期最多月数(为空表示不单独限制)", + "type": "integer", + "nullable": true + }, + "status": { + "description": "状态:0-停用 1-启用(新增默认启用)", + "type": "integer", + "nullable": true + }, + "unit_price": { + "description": "每月单价(分),1 月 = 30 天", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "effective_from", + "id", + "name", + "scope_type" + ], + "type": "object" + }, + "DtoSilencePolicyStatusParams": { + "properties": { + "id": { + "description": "政策 ID", + "minimum": 0, + "type": "integer" + }, + "status": { + "description": "状态:0-停用 1-启用", + "type": "integer", + "nullable": true + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "DtoSilencePolicyMatchResponse": { + "properties": { + "matched": { + "description": "是否匹配到政策(未匹配时不能付费延期,特殊延期仍可申请)", + "type": "boolean" + }, + "package_id": { + "description": "套餐 ID", + "minimum": 0, + "type": "integer" + }, + "policy": { + "$ref": "#/components/schemas/DtoSilencePolicyResponse" + } + }, + "type": "object" + }, + "DtoSilenceRecordResponse": { + "properties": { + "after_deadline_at": { + "description": "变更后截止时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "after_status": { + "description": "变更后状态", + "type": "string", + "nullable": true + }, + "approval_instance_id": { + "description": "审批实例", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "asset_id": { + "description": "资产 ID", + "minimum": 0, + "type": "integer" + }, + "asset_type": { + "description": "资产类型", + "type": "string" + }, + "batch_id": { + "description": "批次", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "before_deadline_at": { + "description": "变更前截止时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "before_status": { + "description": "变更前状态", + "type": "string", + "nullable": true + }, + "change_days": { + "description": "变化天数", + "type": "integer" + }, + "created_at": { + "description": "时间", + "format": "date-time", + "type": "string" + }, + "default_days": { + "description": "默认值快照", + "type": "integer", + "nullable": true + }, + "event_name": { + "description": "事件名称", + "type": "string" + }, + "event_type": { + "description": "事件:set/adjust/expired/ended/extend/exchange/backfill/realname_backfill", + "type": "string" + }, + "exchange_no": { + "description": "换货单号", + "type": "string" + }, + "extension_id": { + "description": "延期明细", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "id": { + "description": "流水 ID", + "minimum": 0, + "type": "integer" + }, + "is_adjusted": { + "description": "是否调整", + "type": "boolean" + }, + "operator_id": { + "description": "操作人", + "minimum": 0, + "type": "integer" + }, + "operator_type": { + "description": "操作人类型", + "type": "string" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "period_days": { + "description": "沉默期天数", + "type": "integer", + "nullable": true + }, + "reason": { + "description": "原因", + "type": "string" + }, + "trigger_source": { + "description": "触发来源", + "type": "string" + } + }, + "type": "object" + }, + "DtoSilenceSwitchResponse": { + "properties": { + "enabled": { + "description": "总开关是否打开", + "type": "boolean" + }, + "precheck": { + "description": "打开前检查各项结果", + "items": { + "$ref": "#/components/schemas/DtoSilenceSwitchPrecheckItem" + }, + "type": "array" + }, + "precheck_passed": { + "description": "打开前检查是否全部通过", + "type": "boolean" + } + }, + "type": "object" + }, + "DtoSilenceSwitchPrecheckItem": { + "properties": { + "code": { + "description": "检查项:unconfigured_packages / unbackfilled_usages / default_policy_missing / approval_scene_missing", + "type": "string" + }, + "count": { + "description": "数量(前两项为未满足的数量,后两项为已具备的数量)", + "type": "integer" + }, + "name": { + "description": "检查项名称", + "type": "string" + }, + "passed": { + "description": "是否满足", + "type": "boolean" + } + }, + "type": "object" + }, + "DtoSilenceSwitchRequest": { + "properties": { + "enabled": { + "description": "目标状态:true 打开 / false 关闭(应急关闭不做检查)", + "type": "boolean", + "nullable": true + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "DtoSilenceUsagePageResult": { + "properties": { + "items": { + "description": "列表", + "items": { + "$ref": "#/components/schemas/DtoSilenceUsageItem" + }, + "type": "array" + }, + "page": { + "description": "页码", + "type": "integer" + }, + "page_size": { + "description": "每页数量", + "type": "integer" + }, + "total": { + "description": "总数", + "type": "integer" + } + }, + "type": "object" + }, + "DtoSilenceUsageItem": { + "properties": { + "asset_id": { + "description": "资产 ID", + "minimum": 0, + "type": "integer" + }, + "asset_identifier": { + "description": "资产标识", + "type": "string" + }, + "asset_type": { + "description": "资产类型", + "type": "string" + }, + "created_at": { + "description": "购买时间", + "format": "date-time", + "type": "string" + }, + "order_no": { + "description": "订单号", + "type": "string" + }, + "package_id": { + "description": "套餐 ID", + "minimum": 0, + "type": "integer" + }, + "package_name": { + "description": "套餐名称", + "type": "string" + }, + "package_usage_id": { + "description": "套餐记录 ID", + "minimum": 0, + "type": "integer" + }, + "shop_id": { + "description": "资产所属店铺", + "minimum": 0, + "type": "integer", + "nullable": true + }, + "shop_name": { + "description": "店铺名称", + "type": "string" + }, + "silence": { + "$ref": "#/components/schemas/DtoPackageSilenceView" + } + }, + "type": "object" + }, + "DtoPackageSilenceView": { + "properties": { + "extendable": { + "description": "是否显示延期入口(沉默期已过期的待生效套餐)", + "type": "boolean" + }, + "silence_adjust_pending": { + "description": "购买时调整沉默期的审批是否进行中", + "type": "boolean" + }, + "silence_deadline_at": { + "description": "当前沉默期截止时间(截止日 23:59:59)", + "format": "date-time", + "type": "string", + "nullable": true + }, + "silence_expired_at": { + "description": "最近一次过期时间", + "format": "date-time", + "type": "string", + "nullable": true + }, + "silence_extended_days": { + "description": "累计已延期天数", + "type": "integer" + }, + "silence_extension_count": { + "description": "累计延期次数", + "type": "integer" + }, + "silence_free_extension_used": { + "description": "已使用免费延期次数", + "type": "integer" + }, + "silence_paid_extended_days": { + "description": "其中付费延期累计天数", + "type": "integer" + }, + "silence_period_days": { + "description": "沉默期天数", + "type": "integer", + "nullable": true + }, + "silence_start_at": { + "description": "沉默期起算时间(购买支付成功时间)", + "format": "date-time", + "type": "string", + "nullable": true + }, + "silence_status": { + "description": "沉默期状态:waiting-待实名 expired-沉默期已过期 ended-沉默期已结束;待实名但已过截止的按 expired 返回", + "type": "string" + }, + "silence_status_name": { + "description": "沉默期状态名称(待实名 / 延期待实名 / 沉默期已过期 / 沉默期已结束)", + "type": "string" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "BearerAuth": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "jwt" + }, + "AgentOpenAPIAccount": { + "description": "代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001。", + "in": "header", + "name": "X-Agent-Account", + "type": "apikey" + }, + "AgentOpenAPINonce": { + "description": "调用方每次请求自行生成的随机串,不需要平台提前分配;同一账号在 5 分钟内不可重复。", + "in": "header", + "name": "X-Agent-Nonce", + "type": "apikey" + }, + "AgentOpenAPIPassword": { + "description": "代理账号当前登录密码,同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名。", + "in": "header", + "name": "X-Agent-Password", + "type": "apikey" + }, + "AgentOpenAPISign": { + "description": "调用方按签名规则实时计算的 HMAC-SHA256 小写十六进制签名,不是固定值。", + "in": "header", + "name": "X-Agent-Sign", + "type": "apikey" + }, + "AgentOpenAPITimestamp": { + "description": "调用方生成的请求发起时间,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,必须与签名原文 timestamp 行一致。", + "in": "header", + "name": "X-Agent-Timestamp", + "type": "apikey" + }, + "Combination": { + "group": [ + { + "id": 1892463 + }, + { + "id": 1892464 + }, + { + "id": 1892465 + }, + { + "id": 1892466 + }, + { + "id": 1892467 + } + ], + "type": "combination" + } + } + } +} diff --git a/docs/权限.md b/docs/权限.md index fec6015..19d0b3c 100644 --- a/docs/权限.md +++ b/docs/权限.md @@ -366,3 +366,72 @@ node scripts/tests/verifyBuild.mjs --serve --polling C 端同步项和接口覆盖/验证见 [本次接入交付说明](./2026-10-26-轮询账户限流-后台接入交付.md)。 本次验证:191 项回归通过;类型、生产构建、代码/样式/格式与 UTF-8 检查通过。仅本地 mock 验收,没有写入菜单数据、推送或部署;生产构建仍有项目既有循环依赖与大 chunk 警告。 + +## 订单分类与沉默期(2026-10-28) + +后台实现已按 Apifox 定义补齐。以下为实际路由和前端权限约定;后端仍需创建真实菜单/按钮记录并独立校验权限与数据范围。未推送、部署或写入服务器菜单,真实支付/审批/退款/导出需测试环境联调。准确范围见 [执行记录](../openspec/changes/add-order-segmentation-and-package-silence/implementation.md)。 + +### 新增可见菜单 + +| 菜单名称 | URL | 父菜单 | 菜单权限 | +| --- | --- | --- | --- | +| 平台订单 | `/order-management/platform-orders` | 订单管理 `/order-management` | `orders:platform_page` | +| 代理商订单 | `/order-management/agent-orders` | 订单管理 | `orders:agent_page` | +| 在线支付订单 | `/order-management/online-orders` | 订单管理 | `orders:online_page` | +| 延期订单 | `/package-management/silence-orders` | 套餐管理 `/package-management` | `package_silence:orders_page` | +| 延期批次与异常 | `/package-management/silence-batches` | 套餐管理 | `package_silence:batches_page` | +| 沉默期临期/过期套餐 | `/package-management/silence-usages` | 套餐管理 | `package_silence:usages_page` | +| 延期政策 | `/package-management/silence-policies` | 套餐管理 | `package_silence:policies_page`(并需 `package_silence:policy`) | +| 导出平台订单 | `/asset-management/export-task-management/export-order-platform` | 导出管理 `/asset-management/export-task-management` | `export_task:order_platform_page` | +| 导出代理商订单 | `/asset-management/export-task-management/export-order-agent` | 导出管理 | `export_task:order_agent_page` | +| 导出在线支付订单 | `/asset-management/export-task-management/export-order-online` | 导出管理 | `export_task:order_online_page` | +| 导出延期订单 | `/asset-management/export-task-management/export-silence-order` | 导出管理 | `export_task:package_silence_order_page` | +| 导出沉默期套餐 | `/asset-management/export-task-management/export-silence-usage` | 导出管理 | `export_task:package_silence_usage_page` | + +代理账号仅可配置平台/代理商普通订单及其导出;在线普通订单及导出只允许 user_type=1/2。企业不得配置普通订单菜单。旧普通订单列表/详情保留。沉默期模块没有额外虚构身份范围,按后端真实授权及数据范围工作。 + +### 新增隐藏详情菜单 + +| 菜单名称 | URL | 父菜单 | 菜单权限 | +| --- | --- | --- | --- | +| 延期订单详情 | `/package-management/silence-orders/detail/:id` | 套餐管理 | `package_silence:order_detail_page` | +| 延期批次详情 | `/package-management/silence-batches/detail/:id` | 套餐管理 | `package_silence:batch_detail_page` | + +直接访问详情同时要求对应列表页权限、隐藏详情菜单权限和下表详情按钮权限;不能只配隐藏详情菜单。导出详情沿用已有隐藏 `export_task:detail_page` 与已有导出详情 URL。仅超管专属配置不列入普通菜单清单。 + +### 新增及沿用按钮权限 + +| 操作 | 权限编码 | +| --- | --- | +| 下单时调整本次沉默期(两个单项购买入口) | `package_silence:override` | +| 扫码付费延期、单项特殊延期 | `package_silence:extend` | +| 批量特殊延期(含 CSV 导入) | 同时需要 `package_silence:extend`、`package_silence:extend_batch` | +| 登记线下付费延期 | `package_silence:offline` | +| 异常明细重试/关闭 | `package_silence:exception` | +| 政策查询/新增/编辑/启停/匹配 | `package_silence:policy` | +| 查看延期订单详情 | `package_silence:order_detail` | +| 查看延期批次详情 | `package_silence:batch_detail` | +| 创建延期订单导出 | `package_silence:order_export` | +| 创建沉默期套餐清单导出 | `package_silence:usage_export` | +| 创建普通订单/查看详情/导出 | 沿用 `orders:add` / `orders:view_detail` / `orders:export` | +| 平台普通订单导出详情/下载 | `export_task:order_platform_detail` / `export_task:order_platform_download` | +| 代理商普通订单导出详情/下载 | `export_task:order_agent_detail` / `export_task:order_agent_download` | +| 在线普通订单导出详情/下载 | `export_task:order_online_detail` / `export_task:order_online_download` | +| 延期订单导出详情/下载 | `export_task:package_silence_order_detail` / `export_task:package_silence_order_download` | +| 沉默期套餐导出详情/下载 | `export_task:package_silence_usage_detail` / `export_task:package_silence_usage_download` | + +沉默期流水、首次购买记录没有在后端文档声明独立按钮权限,继承所属资产/列表页的读取范围,不虚构按钮编码。角色配置继续使用后端真实权限树,需后端提供权限记录和 ID,前端不伪造记录。 + +### 操作边界与验证 + +- 默认商品沉默期 1–90 天,购买覆盖天数仅最小 1;付费延期天数/金额由资格和试算决定,不复制算法。 +- 特殊延期 CSV 为前端解析:表头 `package_usage_id,days`,每行两个整数,转换为文档 items[];不假设后端导入文件格式。 +- 政策免审批完整枚举未在 OpenAPI 给出;已有值原样保留,提供明确的 offline_paid/batch,其他值需填写后端精确编码。政策接口没有 DELETE,不提供删除。 +- 二维码/线下同一快照重试复用幂等键;特殊请求结果不明时提示核对批次并阻止重复提交。待审批、已支付和已完成严格区分。 +- 异常关闭必须填写原因并确认,提示在线订单可能原路退款;不直接修改套餐截止时间。 +- 企业资产五项限制保持;首次购买为只读事实,不回填绑定卡或换货资产;对象缺失隐藏沉默期入口。 +- 236 项测试、类型与生产构建通过;仅本地 mock 验收,没有真实支付、审批、退款、开关切换、导出、推送或部署。 + +### C 端同步(本仓库未修改) + +独立 C 端需接入可选 silence、状态/格式化截止日及 extendable 入口;`/api/c/v1/package-silence` 下的资格、报价、自助下单/qr_content、订单状态刷新、我的延期订单/详情、流水。对象缺失或总开关关闭时隐藏入口;金额单位分、结果取后端,不能复制计费/审批算法,不调用后台 URL。资产归属由后端校验。 diff --git a/openspec/changes/add-order-segmentation-and-package-silence/design.md b/openspec/changes/add-order-segmentation-and-package-silence/design.md new file mode 100644 index 0000000..5f19c78 --- /dev/null +++ b/openspec/changes/add-order-segmentation-and-package-silence/design.md @@ -0,0 +1,26 @@ +## Context + +Vue 3 / Element Plus 后台已有订单列表与详情、两处单项购买、套餐创建/分配/系列授权、资产当前/历史套餐、统一导出、企微审批与角色权限树。本次复用现有能力,延续店铺列表/详情标准、时间格式、条件 >=4 才展开和响应式。 + +## Decisions + +- 原订单列表接收受控分类 props,三个薄路由入口使用独立名称缓存,按分类选择服务与导出场景,不在 query 注入 category。旧列表继续 all;企业路由门禁与请求/处理函数都限制,代理 online 严格禁止,后端继续鉴权。 +- 企业操作限制使用公共账号类型判断;不能仅改一个 v-if:覆盖基础信息、绑定卡、当前套餐、历史套餐及父页处理函数,保留 start/stop、历史与只读数据。 +- 套餐商品默认天数、单次购买覆盖天数、延期天数分别校验,不能共享错误的 90 天上限;null/空与 0 区分;加油包白名单剔除沉默期字段。原实名生效条件保存为只读基线,禁止转换为购买生效。 +- 下单前冻结目标、表单字段和幂等标识;权限取消或资产/套餐切换不发送旧覆盖值。后端决定实际是否适用及是否审批,不靠前端成功提示推定立即生效。 +- silence 可选且不可从其他套餐复用。当前/历史套餐分别展示各自响应;后端不返回对象时不猜测未启用与不适用的区别,不请求资格接口。 +- 首次购买显示使用 formatDateTime;查询与导出共享同一范围转换,ICCID/IMEI 保持字符串。世代与结束原因由服务端返回,记录 DTO 不明时仅完成字段/筛选,记录弹窗保持待补契约。 +- 使用现有预签名上传、凭证、二维码与审批组件,不引入依赖。延期付款金额必须取 quote,不能在浏览器自行按 30 天计费;未知的金额单位与 evidence_refs 结构是实现前置条件。 +- 对文档只列 URL、未列 DTO 的延期页面,先列覆盖清单与契约缺口,不擅自制作可写空壳或推断财务模型。 + +## Validation + +2026-10-28 契约补齐:保存 Apifox 的 21 个后台操作为 `docs/api/package-silence.openapi.json`;全部金额为整数分,前端元输入精确转换。首次购买记录使用 `/api/admin/assets/{asset_type}/{id}/first-purchase-records`,流水/首次购买记录为数组;政策列表也为数组但支持 page/page_size,因此显示上一页/下一页而不伪造总数。政策适用系列/套餐使用既有名称搜索接口。 + +特殊延期 CSV 在前端按 `package_usage_id,days` 两列解析,再以明确的 items[] 提交,不猜后端 import_file_key 的未说明文件格式。二维码/线下订单同一快照重试复用 request_id;特殊接口不定义幂等字段,结果不确定时阻止重复提交并提示核对批次。免审批场景保留真实返回的字符串,提供文档明确的 offline_paid/batch;其他编码需后端提供,不猜完整枚举。 + +先记录当前 198 项基线与 dirty worktree;新增精确请求/角色/字段校验及组件行为测试,覆盖企业 5 项操作但保留停复机、三分类服务、导出 false/0、套餐天数边界、购买覆盖与原因、无权限不发字段、加油包剔除、首次购买时间与空值、未知错误和 0 元退款。运行全量 Node 测试、vue-tsc、受影响文件 lint/format/encoding、生产构建与针对性本地 mock;不把不完整 DTO 的自造响应作为后端验收。 + +## Rollout and risks + +无数据库迁移,不自动开沉默期总开关,旧订单路由/导出与现有菜单配置保留。新菜单是否可见仍需后端菜单数据授权;清单在实施后按实际 URL/权限交付。真实支付与审批、资产范围和文件内容需测试环境联调。C 端为独立工程,仅提供同步清单。 diff --git a/openspec/changes/add-order-segmentation-and-package-silence/implementation.md b/openspec/changes/add-order-segmentation-and-package-silence/implementation.md new file mode 100644 index 0000000..f95819e --- /dev/null +++ b/openspec/changes/add-order-segmentation-and-package-silence/implementation.md @@ -0,0 +1,61 @@ +# 执行记录:订单分类与沉默期 + +## 当前结论 + +本后台代码已按汇总文档及用户授权的 Apifox 定义补齐,原先因缺少 DTO 挂起的延期办理、政策、批次异常和记录弹窗已实现。未修改独立 C 端,未推送、部署或调用真实业务写入;不能把本地 mock 验证等同于线上验收。 + +## 契约来源与差异 + +21 个后台操作读取自用户授权的 Apifox 分享文档,留存无密码快照 [package-silence.openapi.json](../../../docs/api/package-silence.openapi.json)。本会话通过官方 Apifox MCP Server 0.0.18 的 --oas 模式验证文档读取,不代表直接实时连接整个私有项目;线上变更后需重新读取快照。 + +- 所有延期金额、单价均为整数分;元输入做精确整数转换,不能使用浮点金额直接提交。 +- 首次购买记录真实路径为 `GET /api/admin/assets/{asset_type}/{id}/first-purchase-records`,不是汇总里带 package-silence 前缀的路径。 +- 首次购买记录、沉默期流水为数组;政策列表也是数组且有 page/page_size,因此政策页不伪造 total。 +- 更新政策与启停请求含 id;特殊收款时间是 offline_paid_at,线下订单是 paid_at。 +- 两个沉默期导出场景依据 orders/usages 各自文档启用,filters 与列表同范围;通用导出接口对 query 的类型为字符串值,沿用 JSON 字符串 filters。之前三类普通订单仍保留汇总明确要求的对象 filters。 + +## 实现覆盖 + +| 范围 | 已实现 | +| --- | --- | +| 分类订单 | 平台/代理/在线三页、独立精确路径与 scene、旧列表详情及历史导出保留;代理禁止 online、企业禁止普通订单 | +| 企业资产限制 | 有效期、已用量、停用、轮询、实名状态五类入口和处理函数限制;授权资产停复机保持;未找到仍为普通未找到 | +| 套餐与购买 | 商品默认 1–90 天、加油包剔除、生效条件限制;单项/批量/系列分配写前校验;两处下单覆盖无 90 天上限并有权限及原因校验 | +| 资产沉默期 | 按可选 silence 展示真实状态/截止/累计;真实 usage ID 打开延期与流水;对象缺失隐藏 | +| 扫码延期 | 资格、后端报价、wechat/alipay 二维码、确认后创建、订单刷新;终态/错误/关闭停止刷新;不复制计价算法 | +| 线下登记 | 实收元转分、与报价一致、收款方式字典、交易单号、RFC3339 付款时间、1–5 张凭证、申请依据;待审批不显示已生效 | +| 特殊与批量 | 五类特殊延期、原因/依据、优惠价收款;CSV 两列转 items[],校验表头/整数/重复 ID;批量需 extend 和 extend_batch;展示真实批次与错误行 | +| 订单与批次 | 列表/详情/筛选、订单导出;批次详情逐行异常;重试/关闭需原因及确认,关闭提示可能原路退款 | +| 临期与过期 | expiring/expired/all 视图、默认 7 天、资产/店铺/套餐/截止范围及导出;延期与流水入口 | +| 延期政策 | 列表、详情加载、新增/全量编辑、启停、匹配、重叠错误;系列/套餐名称搜索;无 DELETE 不提供删除 | +| 首次购买 | 卡/设备时间列、RFC3339 筛选/导出、资产详情;列表记录入口和只读世代历史,展示来源/订单/结束原因;不伪造绑定卡或换货时间 | +| 配置与兼容 | 专用总开关/precheck/failed_items、通用配置只读、企微审批类型、六项真实权限识别、1260–1275 提示及零元退款 | + +同一二维码/线下请求快照重试使用相同 request_id,不自动重发写请求。特殊延期没有文档定义的幂等参数,传输结果不确定时阻止再次提交并提示先核对批次;不得直接修改截止时间替代异常处理。 + +CSV 为本地解析的 `package_usage_id,days` 两列格式,经文档 items[] 提交,未猜测后端 import_file_key 的文件格式。政策免审批场景完整枚举未在 OpenAPI 列出:提供明确的 offline_paid、batch,并保留服务端现有值;其他编码由后端提供后填写,不根据中文自行造值。批次及明细未知状态原样显示,不推断“已完成”。 + +## 验证 + +当前全量 **236 项测试通过**(保留原 198 项,上一阶段 18 项,本阶段 20 项)。覆盖 21 个精确 HTTP 操作、金额精度与 0、CSV、报价失效、权限撤销/确认取消、幂等重试、迟到响应、线下凭证与审批、特殊结果不确定防重复、政策完整提交、异常 ID/原因、导出白名单与模板编译。 + +类型检查、生产构建、受影响代码 ESLint/Prettier/Stylelint、UTF-8 检查通过。生产构建仍有项目既有常量 v-model、API 循环依赖及大 chunk 警告,未在本轮扩大重构。 + +本地浏览器使用 `127.0.0.1:5190` 全 /api 拦截 mock,核验临期状态与格式化截止时间、31 天后端试算为 60 天、提交确认及订单刷新、政策编辑与 390px 窄屏、批次异常原因确认。上一阶段三类订单/角色门禁/套餐配置/总开关 mock 检查仍保留。没有请求真实支付或审批接口。 + +```powershell +$tests = @(rg --files scripts/tests -g '*.test.mjs') +node --import ./scripts/tests/register-esbuild.mjs --test --test-reporter=spec $tests +.\node_modules\.bin\vue-tsc.cmd --noEmit +node scripts/tests/verifyBuild.mjs +node scripts/tests/verifyBuild.mjs --serve --order-silence +openspec validate add-order-segmentation-and-package-silence --strict +``` + +## 菜单、权限和 C 端 + +完整菜单/URL/父菜单与普通按钮编码见 [权限清单](../../../docs/权限.md#订单分类与沉默期2026-10-28)。不列仅超管专属设置,不写入服务器菜单或伪造权限 ID。 + +C 端需要另行接入:可选 silence 状态/截止与 extendable 入口;资格、后端报价、自助订单/qr_content、订单状态刷新、我的延期订单和流水;总开关关闭或对象缺失隐藏入口;金额为分、不能复制计费/审批规则。对应 `/api/c/v1/package-silence`,不复用后台 URL;资产归属鉴权交给后端。本仓库未修改 C 端代码。 + +真实测试环境仍需验收:数据范围与权限、实际商户支付及查单、企微通过/驳回、异常关闭退款、上传文件和导出下载内容。全部检查只证明前端实现及本地契约行为,不证明后端已部署兼容。 diff --git a/openspec/changes/add-order-segmentation-and-package-silence/proposal.md b/openspec/changes/add-order-segmentation-and-package-silence/proposal.md new file mode 100644 index 0000000..34f220f --- /dev/null +++ b/openspec/changes/add-order-segmentation-and-package-silence/proposal.md @@ -0,0 +1,43 @@ +# Change: 订单分类拆页与套餐沉默期后台接入 + +## Why + +依据 `docs/2026-10-27-订单拆分至沉默期-前端修改汇总(1).md`,后台尚未接入订单分类列表、企业账号操作限制、套餐沉默期及延期、首次购买套餐时间。需要保留既有订单详情、付款/审批、套餐分配与资产功能,并区分前端实现和真实后端联调。 + +用户于 2026-10-28 明确要求“根据文档来写提案,然后执行”,本提案完成严格校验后按该授权实施。延续只修改本后台仓库的范围,不修改独立 H5/C 端,不推送或部署。工作区已有四个轮询修复文件必须保留。 + +## What Changes + +- 订单管理新增平台、代理商、在线支付三页,复用原列表及详情、筛选、创建能力;各自请求 `/orders/platform|agent|online`,不提交类别参数,导出分别使用 `order_platform|order_agent|order_online`。保留旧列表及历史 `order` 导出。代理隐藏在线支付页,企业隐藏订单菜单并阻止页面请求;不改变后端数据范围。 +- 企业账号隐藏并在处理函数阻止资产改有效期、改已用量、停用、开关轮询、改实名状态五项操作,保留授权资产的停复机及读取。未找到不改写为无权限。 +- 正式套餐新增默认沉默期(1–90 天)与付费延期开关;实名即生效必填,新建默认 30 天;购买即生效可选;加油包不显示、不提交。新增未配置筛选与导出参数、详情展示;明确生效条件不可从实名改成购买,购买改实名需同请求带天数。 +- 后台两处单项下单入口增加有权限才可填写的 `silence_period_days`、`silence_reason`;购买覆盖天数仅最小 1、不套用套餐配置 90 天上限,天数存在时原因必填。保留后端审批结果,不将待审批说成已延期。 +- 资产当前套餐、历史套餐按可选 `silence` 对象展示状态、截止日、累计与审批信息;对象缺失时隐藏,入口按 `extendable` 与权限控制。流水使用真实 usage ID。 +- 卡/设备新增首次购买时间列、RFC3339 范围筛选、导出参数、详情与只读记录弹窗;绑定卡不伪造首次购买时间,换货历史以服务端世代与结束原因展示,不在前端推算或回填。 +- 新增沉默期专用开关页(仅超管),展示 `precheck` 与 1275 的 `failed_items`;通用系统配置不写此开关。增加沉默期审批业务类型与六个权限点的前端识别;角色权限树沿用服务器数据,不伪造权限 ID。 +- 完整目标包含延期资格/试算/二维码、线下付款、特殊单项/批量延期、延期订单/批次异常、临期/过期清单、延期政策以及两个沉默期导出场景;金额、审批与状态均以服务端事实为准,不复制后端计费规则。 +- 错误码 1260–1275 保留服务端具体原因、限制与异常行;零元退款允许提交,后台线下购买未实名套餐仍按后端待生效状态展示。 + +## Contract gaps and execution boundary + +首次执行时,`docs/admin-openapi.yaml`、被引用的沉默期技术方案与后端规范未提供,因此对应可写业务未实施。2026-10-28 用户补充并授权带密码的 Apifox 分享文档访问后,已取得 21 个后台操作,保存为 `docs/api/package-silence.openapi.json`(不含密码)。下面四组缺口现已按该权威定义补齐,任务第 3 节已执行;真实环境支付/审批/导出仍需联调验收。 + +初次记录的缺口(现已由 Apifox 补齐): + +1. 延期政策 POST/PUT 字段、字段类型、级别/范围/重叠规则、列表/详情响应与状态枚举。 +2. 资格 options、quote、二维码及线下订单响应的字段结构、金额单位、订单 ID/状态/支付状态;超时重试的 request_id 复用约定。 +3. 特殊延期优惠收款字段名、amount 与 paid_amount 单位、evidence_refs 元素结构、批量部分成功/异常响应完整结构。 +4. 流水、延期订单、批次/明细、usage 清单、首次购买记录的分页结构、筛选、时间与状态字段;未定义处不得擅自命名。 + +执行顺序:先完成本文可确定的订单、企业限制、套餐/下单字段、首次购买字段/筛选、审批类型和错误提示;契约缺口独立保持未完成。若执行期间补齐规范,再继续完成相应延期业务。不得以全部接口存在或本地测试通过代替完整业务验收。 + +## Impact + +- Affected specs: 新增 `order-segmentation-and-package-silence` 接入约束;当前无已发布 specs,以 ADDED 记录,不伪造 MODIFIED 基线。 +- Affected code: order、packageManage、shopPackageAllocation、asset、card、device、wecom、systemConfig、exportTask 的类型/服务/界面、路由与权限、相关工具及回归测试。 +- 与 `update-october-batch-one-admin-integration` 的订单支付信息、退款审批状态,以及 `update-package-allocation-expiry-base-override` 的分配覆盖保持兼容;不重置此前轮询修复。 +- 不产生真实支付、审批、资产写入、导出、开关切换或 CSV 导入;写操作验证仅本地 mock。 + +## Delivery + +交付提案、实际改动与测试证据;菜单清单给出名称、URL、父菜单、普通按钮编码,不重复列仅超管专属项;单列 C 端同步要求和确实因契约缺失未完成的工作。 diff --git a/openspec/changes/add-order-segmentation-and-package-silence/specs/order-segmentation-and-package-silence/spec.md b/openspec/changes/add-order-segmentation-and-package-silence/specs/order-segmentation-and-package-silence/spec.md new file mode 100644 index 0000000..666996e --- /dev/null +++ b/openspec/changes/add-order-segmentation-and-package-silence/specs/order-segmentation-and-package-silence/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### Requirement: 订单分类与角色边界 + +The admin frontend SHALL expose independent platform, agent and online order pages using the corresponding documented endpoints and export scenes without category query parameters. It MUST preserve legacy order history and the shared detail contract. Enterprise accounts MUST not query or mutate orders, and agents MUST not access online orders. + +#### Scenario: 分类查询和导出 + +- **WHEN** 平台账号打开某类订单或创建该类导出 +- **THEN** 请求 MUST 使用对应路径和 scene,保留原筛选,不改变订单详情接口。 + +#### Scenario: 不允许的账号 + +- **WHEN** 企业账号尝试进入订单页,或代理进入在线订单 +- **THEN** 菜单和页面请求 MUST 被限制,不以按钮授权覆盖身份限制。 + +### Requirement: 企业资产操作限制 + +The frontend SHALL hide and block expiry adjustment, used-data adjustment, deactivation, polling toggle and realname-status adjustment for enterprise accounts across all existing asset entrypoints while preserving authorized start/stop and reads. A not-found asset response MUST remain ordinary not-found feedback. + +#### Scenario: 已授权企业资产 + +- **WHEN** 企业账号查看已授权卡或设备 +- **THEN** 五项禁止操作 MUST 不可见且处理函数不能提交,停复机仍按原权限可用。 + +### Requirement: 套餐默认沉默期与购买覆盖 + +The frontend SHALL distinguish package defaults (1–90 days), purchase overrides (minimum 1, no documented upper bound) and backend-priced extensions. Formal activation-based packages MUST require defaults with 30 days prefilled only for creation; addons MUST omit silence fields. Forbidden expiry-base transitions MUST be prevented where source data is known and backend rejection preserved otherwise. + +#### Scenario: 默认配置 + +- **WHEN** 新建实名即生效正式套餐或将购买生效改为实名生效 +- **THEN** 保存 MUST 携带有效 silence_period_days,不能从实名改为购买生效。 + +#### Scenario: 单次购买覆盖 + +- **WHEN** 有 override 权限的用户填写本次沉默期 +- **THEN** 请求 MUST 同时包含有效天数与原因;无权限或未填写时不发覆盖字段,等待审批不能显示为已延期。 + +### Requirement: 可选沉默期与首次购买事实展示 + +The frontend SHALL show silence status and timestamps only when the server returns the applicable silence object. First-purchase timestamps SHALL remain read-only server facts with shared range filtering/export and generation history, never synthesized for bound cards or exchanges. + +#### Scenario: 总开关关闭 + +- **WHEN** 套餐响应不包含 silence +- **THEN** 沉默期状态及延期入口 MUST 隐藏,不推算默认截止时间。 + +#### Scenario: 首次购买范围 + +- **WHEN** 用户按首次购买时间查询卡或设备并导出 +- **THEN** 列表和导出 MUST 使用相同 RFC3339 范围,空时间仍显示为空且不开放修改。 + +### Requirement: 延期与审批使用真实后端契约 + +The frontend SHALL use documented eligibility, quote, payment, special extension, policy, batch and history contracts. Amounts and state transitions MUST remain backend facts. Undefined DTO fields or money units MUST be reported as missing prerequisites rather than implemented through guessed writes or arbitrary JSON forms. + +#### Scenario: 缺少财务契约 + +- **WHEN** 汇总未定义政策保存字段、金额单位或收款结构且权威文档不可用 +- **THEN** 对应实现项 MUST 保持未完成,交付说明准确列出需要补充的契约。 + +#### Scenario: 专用总开关与错误反馈 + +- **WHEN** 超管切换总开关或后端拒绝延期 +- **THEN** 前端 MUST 走专用开关接口并展示 precheck/failed_items 或具体错误;不得用通用系统配置绕过检查。 + +### Requirement: 交付与验证边界 + +The change SHALL preserve existing routes, permissions, worktree edits and successful payment/approval behaviors. It MUST include targeted regression tests and an actual menu/permission handoff, excluding superadmin-only configuration from ordinary-user setup. H5/C-end work and real-backend validation MUST be disclosed separately. + +#### Scenario: 执行交付 + +- **WHEN** 本轮工作停止或完成 +- **THEN** 交付 MUST 区分已实现、已本地验证、真实联调待办与缺契约未实现项,不以 mock 或服务数量宣称全部完成。 diff --git a/openspec/changes/add-order-segmentation-and-package-silence/tasks.md b/openspec/changes/add-order-segmentation-and-package-silence/tasks.md new file mode 100644 index 0000000..0a4e058 --- /dev/null +++ b/openspec/changes/add-order-segmentation-and-package-silence/tasks.md @@ -0,0 +1,33 @@ +## 1. 提案与契约 + +- [x] 1.1 完整核对汇总文档、现有代码、未归档提案和四个轮询 dirty 文件。 +- [x] 1.2 严格校验本提案,按用户明确的先提案后执行授权实施。 +- [x] 1.3 通过用户授权的 Apifox 分享文档收齐 21 个后台操作及金额单位,保留无密码的 OpenAPI 快照;首次购买记录以真实 `/api/admin/assets/{asset_type}/{id}/first-purchase-records` 为准。 + +## 2. 文档明确的后台改动 + +- [x] 2.1 三类订单服务/独立路由/复用列表/独立导出场景,保留旧列表与详情。 +- [x] 2.2 代理隐藏 online、企业隐藏订单/禁止请求;企业五项资产操作所有入口和处理函数限制,保留停复机。 +- [x] 2.3a 套餐默认沉默期与延期开关、未配置筛选/导出、详情、加油包剔除与商品自身生效条件转换校验。 +- [x] 2.3b 分配/批量/系列覆盖写入前复用套餐详情校验,不猜候选 metadata;覆盖不合法不发写请求,保留后端 1262 约束及原 Body。 +- [x] 2.4 两处后台单项下单覆盖天数/原因、权限、正确边界与请求白名单。 +- [x] 2.5 资产当前/历史套餐按可选 silence 展示状态、截止日、累计和审批状态,不伪造延期完成。 +- [x] 2.6 卡/设备首次购买时间列/筛选/导出/详情,保持只读,不在绑定卡填充。 +- [x] 2.7 沉默期开关专用接口/超管页/检查失败展示,通用系统配置只读。 +- [x] 2.8 企微沉默期审批类型、六项权限识别、错误码提示、零元退款校验与等待实名文案核查。 + +## 3. 完整契约补齐后实施 + +- [x] 3.1 资格/后端报价与二维码延期、支付状态刷新、同快照幂等键复用与迟到响应隔离。 +- [x] 3.2 线下付款登记、元转整数分、金额一致性、1~5 张凭证与真实审批状态反馈。 +- [x] 3.3 特殊单项/批量 CSV 转换为文档 items[]、原因依据、优惠收款、逐行失败与异常状态;无幂等接口的超时不自动重发。 +- [x] 3.4 延期订单/详情/导出、批次/明细/原因确认后重试关闭、临期/过期 usage 清单/导出。 +- [x] 3.5 延期政策列表/新增/编辑/匹配/启停/重叠反馈;后端未提供 DELETE,不虚构删除。 +- [x] 3.6 沉默期流水与卡/设备列表、资产详情的首次购买记录弹窗。 + +## 4. 验证与交付 + +- [x] 4.1 精确请求与角色、金额/天数边界、0/false/null、异步生命周期、审批状态与错误行回归。 +- [x] 4.2 类型、全量 236 项测试、构建、lint/样式/格式/编码,保留用户已有修改和声明;执行证据见 implementation.md。 +- [x] 4.3 已实施范围的本地 mock 页面与窄屏验收;真实后端未验证内容单列。 +- [x] 4.4 实际菜单/URL/父菜单/普通按钮权限、C 端同步清单、接口差异与真实联调待办交付,已写入 docs/权限.md 与 implementation.md;未部署,不归档为线上已验收。 diff --git a/scripts/tests/assetBindingCardRender.test.mjs b/scripts/tests/assetBindingCardRender.test.mjs index 3b2e3f4..20cecc2 100644 --- a/scripts/tests/assetBindingCardRender.test.mjs +++ b/scripts/tests/assetBindingCardRender.test.mjs @@ -87,11 +87,17 @@ function mountBasicInfo(t, userType, permissions = []) { }, '../composables/useAssetFormatters': formatters, '@/composables/useAuth': { useAuth: () => ({ hasAuth }) }, + '@/components/business/package-silence/SilenceRecordsDialog.vue': { + default: vue.defineComponent(() => () => null) + }, '@/store/modules/user': { useUserStore: () => user }, '@/config/constants': { JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } } }, '@/utils/business/format': { formatDateTime: (value) => String(value || '') }, + '@/utils/business/packageSilence': evaluate( + readFileSync('src/utils/business/packageSilence.ts', 'utf8') + ), '@/utils/business/expiryEstimate': { getExpiryEstimateText: () => '-', getExpiryEstimateTooltip: () => '', @@ -179,6 +185,29 @@ function mountBasicInfo(t, userType, permissions = []) { const textOf = (node) => node.tag === '#comment' ? '' : node.text + node.children.map(textOf).join('') + +test('enterprise account hides polling and realname writes while authorized start/stop remain', async (t) => { + const { root, asset, user } = mountBasicInfo(t, 4) + user.permissions = [ + 'device_info:auto_polling', + 'iot_info:auto_polling', + 'iot_info:update_realname_status', + 'bound_card:update_realname_status', + 'iot_info:start', + 'iot_info:stop' + ] + await vue.nextTick() + assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/) + assert.equal(nodesWithClass(root, 'binding-card').length, 2) + asset.asset_type = 'card' + asset.network_status = 0 + await vue.nextTick() + assert.match(textOf(root), /启用此卡/) + assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/) + asset.network_status = 1 + await vue.nextTick() + assert.match(textOf(root), /停用此卡/) +}) const nodesWithClass = (node, className) => [ ...(String(node.props.class || '') .split(' ') diff --git a/scripts/tests/assetInfoState.test.mjs b/scripts/tests/assetInfoState.test.mjs index e1f3410..90f9ddc 100644 --- a/scripts/tests/assetInfoState.test.mjs +++ b/scripts/tests/assetInfoState.test.mjs @@ -557,6 +557,8 @@ function pageHandlers(state) { visit(source) assert.equal(declarations.length, wanted.size) const bindings = { + useUserStore: () => ({ info: { user_type: 1 } }), + canEditRestrictedAsset: (type) => [1, 2, 3].includes(Number(type)), cardInfo: state.cardInfo, fetchAssetDetail: state.fetchAssetDetail, loadPackageList: state.loadPackageList, diff --git a/scripts/tests/businessEntitySelect.test.mjs b/scripts/tests/businessEntitySelect.test.mjs new file mode 100644 index 0000000..76849b1 --- /dev/null +++ b/scripts/tests/businessEntitySelect.test.mjs @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' +import * as vue from 'vue' + +const read = (path) => readFileSync(path, 'utf8') +function evaluate(source, imports = {}) { + const code = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', code)( + (name) => { + if (name.endsWith('.vue')) return { default: {} } + assert.ok(imports[name], `Unexpected import ${name}`) + return imports[name] + }, + module, + module.exports + ) + return module.exports +} +const format = evaluate(read('src/utils/business/format.ts')) +const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format }) +const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway }) +const response = (data) => ({ code: 0, data }) +const deferred = () => { + let resolve, reject + const promise = new Promise((a, b) => { + resolve = a + reject = b + }) + return { promise, resolve, reject } +} +const tick = async () => { + await vue.nextTick() + await Promise.resolve() + await vue.nextTick() +} + +function serviceHarness(overrides = {}) { + const calls = [] + const service = new Proxy( + {}, + { + get: + (_, method) => + async (...args) => { + calls.push({ method, args }) + return overrides[method] ? overrides[method](...args) : response({ items: [], total: 0 }) + } + } + ) + const modules = { + './packageManage': { PackageManageService: service }, + './packageSeries': { PackageSeriesService: service }, + './shop': { ShopService: service }, + './card': { CardService: service }, + './device': { DeviceService: service }, + './account': { AccountService: service }, + './packageSilence': { PackageSilenceService: service }, + '@/utils/business/gateway': gateway + } + return { ...evaluate(read('src/api/modules/businessOptions.ts'), modules), calls } +} +test('entity search uses documented keyword/name fields and returns labels, never database IDs as labels', async () => { + const h = serviceHarness({ + getPackages: () => response({ items: [{ id: 8, package_name: '实名套餐' }], total: 41 }), + getPackageSeries: () => response({ items: [{ id: 9, series_name: '流量系列' }], total: 1 }), + getShops: () => response({ items: [{ id: 10, shop_name: '一号代理' }], total: 1 }), + getAccounts: () => + response({ items: [{ id: 11, username: '张三', shop_name: '一号代理' }], total: 1 }), + getStandaloneIotCards: () => + response({ items: [{ id: 12, iccid: '89860001', virtual_no: 'V-01' }], total: 1 }), + getDevices: () => + response({ items: [{ id: 13, virtual_no: 'D-01', device_name: '设备甲' }], total: 1 }) + }) + for (const [kind, method, field] of [ + ['package', 'getPackages', 'package_name'], + ['series', 'getPackageSeries', 'series_name'], + ['shop', 'getShops', 'shop_name'], + ['account', 'getAccounts', 'username'], + ['card', 'getStandaloneIotCards', 'keyword'] + ]) { + const result = await h.searchBusinessOptions(kind, ' 关键字 ', 2) + assert.ok(result.options.length) + assert.equal(typeof result.options[0].value, 'number') + assert.notEqual(result.options[0].label, String(result.options[0].value)) + const call = h.calls.at(-1) + assert.equal(call.method, method) + assert.deepEqual(call.args[0], { page: 2, page_size: 20, [field]: '关键字' }) + assert.equal(result.hasMore, kind === 'package') + } + const assets = await h.searchBusinessOptions('asset', '01') + assert.deepEqual( + assets.options.map((x) => x.value), + ['89860001', 'D-01'] + ) + assert.match(assets.options[0].label, /V-01/) + assert.match(assets.options[1].label, /设备甲/) +}) +test('package record selection uses real usage IDs with asset/package context and readable order labels', async () => { + const h = serviceHarness({ + usages: () => + response({ + items: [ + { + package_usage_id: 99, + package_name: '实名套餐', + asset_identifier: '8986', + order_no: 'ORD-01' + } + ], + total: 1 + }) + }) + const result = await h.searchBusinessOptions('usage', '', 1, { + asset_identifier: '8986', + package_id: 8 + }) + assert.deepEqual(h.calls[0].args[0], { + page: 1, + page_size: 20, + view: 'all', + asset_identifier: '8986', + package_id: 8 + }) + assert.deepEqual(result.options, [{ value: 99, label: '实名套餐 · 8986 · ORD-01' }]) + await h.searchBusinessOptions('usage', '8987', 1, { asset_identifier: '8986' }) + assert.equal(h.calls.at(-1).args[0].asset_identifier, '8987') +}) +test('asset lookup keeps permitted card results when the device lookup fails and reports the partial failure', async () => { + const h = serviceHarness({ + getStandaloneIotCards: () => response({ items: [{ id: 12, iccid: '89860001' }], total: 1 }), + getDevices: () => ({ code: 403, msg: '无设备查看权限' }) + }) + const result = await h.searchBusinessOptions('asset', '') + assert.equal(result.options[0].value, '89860001') + assert.match(result.warning, /设备.*无设备查看权限/) +}) +test('policy selection resolves existing package/series names through detail endpoints and propagates business errors', async () => { + const h = serviceHarness({ + getPackageDetail: () => response({ package_name: '已有套餐' }), + getPackageSeriesDetail: () => response({ series_name: '已有系列' }), + getShops: () => ({ code: 403, msg: '无数据权限' }) + }) + assert.deepEqual(await h.resolveBusinessOption('package', 8), { value: 8, label: '已有套餐' }) + assert.deepEqual(await h.resolveBusinessOption('series', 9), { value: 9, label: '已有系列' }) + await assert.rejects(h.searchBusinessOptions('shop', ''), /无数据权限/) +}) + +function componentHarness({ entity = 'shop', modelValue, search, resolve, allowed = true } = {}) { + const hooks = [], + calls = [], + events = [] + const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true }) + const props = vue.reactive({ entity, modelValue, allowed, clearable: true }) + const runtime = { + ...vue, + onBeforeUnmount: (fn) => hooks.push(fn), + onDeactivated: (fn) => hooks.push(fn), + onActivated() {} + } + const { descriptor } = parse(read('src/components/business/BusinessEntitySelect.vue')) + const component = evaluate(compileScript(descriptor, { id: 'entity-select' }).content, { + vue: runtime, + '@/store/modules/user': { useUserStore: () => user }, + '@/utils/business/gateway': gateway, + '@/utils/business/pollingScheduler': polling, + '@/api/modules/businessOptions': { + BUSINESS_OPTION_LABELS: { + shop: '店铺', + package: '套餐', + series: '系列', + asset: '资产', + account: '操作人' + }, + searchBusinessOptions: async (...args) => { + calls.push(args) + return search ? search(...args) : { options: [], hasMore: false } + }, + resolveBusinessOption: resolve || (async (_, value) => ({ value, label: '已有名称' })) + } + }).default + const scope = vue.effectScope() + const state = scope.run(() => + component.setup(props, { + expose() {}, + emit: (event, value) => { + events.push([event, value]) + props.modelValue = value + } + }) + ) + return { + state, + props, + user, + calls, + events, + dispose: () => { + hooks.forEach((fn) => fn()) + scope.stop() + } + } +} +test('rapid keywords ignore late responses, selection retains its label through later searches and clear emits no ID', async () => { + const old = deferred() + const h = componentHarness({ + search: (_, keyword) => + keyword === 'old' + ? old.promise + : { + options: [ + { value: keyword === 'new' ? 2 : 3, label: keyword === 'new' ? '新店铺' : '其他店铺' } + ], + hasMore: false + } + }) + const pending = h.state.search('old') + await h.state.search('new') + old.resolve({ options: [{ value: 1, label: '旧店铺' }], hasMore: false }) + await pending + assert.deepEqual(h.state.options.value, [{ value: 2, label: '新店铺' }]) + h.state.selection.value = 2 + await h.state.search('other') + assert.deepEqual( + h.state.options.value.map((x) => x.label), + ['新店铺', '其他店铺'] + ) + assert.deepEqual(h.events[0], ['update:modelValue', 2]) + h.state.selection.value = undefined + assert.deepEqual( + h.state.options.value.map((x) => x.label), + ['其他店铺'] + ) + h.dispose() +}) +test('load more appends options and request failures are retryable rather than fabricated empty results', async () => { + let fail = true + const h = componentHarness({ + search: (_, keyword, page) => { + if (keyword === 'fail' && fail) throw new Error('网络失败') + return { options: [{ value: page, label: `店铺${page}` }], hasMore: page < 2 } + } + }) + await h.state.search('') + assert.equal(h.state.hasMore.value, true) + await h.state.load(2) + assert.deepEqual( + h.state.options.value.map((x) => x.value), + [1, 2] + ) + assert.equal(h.state.hasMore.value, false) + await h.state.search('fail') + assert.equal(h.state.error.value, '网络失败') + fail = false + await h.state.load(1) + assert.equal(h.state.error.value, '') + assert.equal(h.calls.at(-1)[1], 'fail') + h.dispose() +}) +test('permission/account/context changes and unmount discard in-flight results without new unauthorized requests', async () => { + for (const action of ['permission', 'account', 'context', 'unmount']) { + const pending = deferred(), + h = componentHarness({ search: () => pending.promise }) + const loading = h.state.search('a') + if (action === 'permission') h.props.allowed = false + if (action === 'account') h.user.info.id = 2 + if (action === 'context') h.props.context = { package_id: 8 } + if (action === 'unmount') h.dispose() + await tick() + pending.resolve({ options: [{ value: 8, label: '已失效数据' }], hasMore: true }) + await loading + assert.deepEqual(h.state.options.value, [], action) + assert.equal(h.state.loading.value, false) + if (action === 'permission' || action === 'unmount') { + await h.state.search('b') + assert.equal(h.calls.length, 1) + } + if (action !== 'unmount') h.dispose() + } +}) +test('existing multi-selection hydrates readable labels and system operator preserves numeric zero', async () => { + const h = componentHarness({ + entity: 'package', + modelValue: [7, 8], + resolve: async (_, value) => ({ value, label: `套餐名${value}` }) + }) + await tick() + assert.deepEqual( + h.state.options.value.map((x) => x.label), + ['套餐名7', '套餐名8'] + ) + h.props.modelValue = [8] + await tick() + assert.deepEqual( + h.state.options.value.map((x) => x.label), + ['套餐名8'] + ) + h.dispose() + const account = componentHarness({ entity: 'account' }) + account.props.includeSystem = true + account.state.selection.value = 0 + assert.deepEqual(account.events.at(-1), ['update:modelValue', 0]) + assert.deepEqual(account.state.options.value, [{ value: 0, label: '系统' }]) + account.dispose() +}) +test('all affected templates compile and raw-ID search controls are replaced throughout the shared pages', () => { + const files = [ + 'src/components/business/BusinessEntitySelect.vue', + 'src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue', + 'src/components/core/forms/art-search-bar/index.vue', + 'src/components/business/package-silence/SilenceManagement.vue', + 'src/components/business/package-silence/SilencePolicyDialog.vue', + 'src/views/order-management/order-list/index.vue', + 'src/views/finance/refund/index.vue', + 'src/views/polling-management/priority-queue/index.vue', + 'src/views/polling-management/polling-pauses/index.vue', + 'src/views/asset-management/phone-asset-association/index.vue', + 'src/views/asset-management/record-management/asset-assign/index.vue' + ] + for (const file of files) { + const { descriptor, errors } = parse(read(file)) + assert.deepEqual(errors, [], file) + assert.deepEqual( + compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors, + [], + file + ) + } + const management = read(files[3]) + assert.match(read(files[2]), /v-show="index < visibleItemCount"/) + for (const field of ['asset_identifier', 'package_id', 'shop_id', 'package_usage_id']) + assert.match(management, new RegExp(`entity\\('${field}'`)) + assert.doesNotMatch(management, /请输入套餐 ID|number\('(?:package_id|shop_id|package_usage_id)'/) + assert.doesNotMatch(read(files[4]), /label: `ID/) + assert.doesNotMatch(read(files[7]), /请输入卡ID/) +}) diff --git a/scripts/tests/orderSilenceBrowserFixtures.mjs b/scripts/tests/orderSilenceBrowserFixtures.mjs new file mode 100644 index 0000000..0f384b2 --- /dev/null +++ b/scripts/tests/orderSilenceBrowserFixtures.mjs @@ -0,0 +1,491 @@ +// 全 API 本地拦截;沉默期响应依据 docs/api/package-silence.openapi.json。 +import { mockApiPlugin as fallback } from './pollingSchedulerBrowserFixtures.mjs' +const time = '2026-10-28T10:00:00+08:00' +const log = [] +let localPaymentStatus = 'pending_payment' +const silence = { + silence_status: 'expired', + silence_status_name: '沉默期已过期', + silence_deadline_at: '2026-10-27T23:59:59+08:00', + silence_period_days: 30, + silence_extended_days: 0, + silence_paid_extended_days: 0, + silence_extension_count: 0, + silence_free_extension_used: 0, + silence_adjust_pending: false, + extendable: true +} +const silenceOrder = () => ({ + id: 7, + order_no: 'LOCAL-SILENCE-7', + status: localPaymentStatus, + status_name: localPaymentStatus === 'completed' ? '已完成' : '待支付', + package_usage_id: 4, + package_id: 8, + package_name: '本地实名套餐', + asset_identifier: '89860426012345678901', + asset_type: 'iot_card', + asset_id: 2, + source: 'admin', + source_name: '后台', + amount: 100, + paid_amount: null, + payment_method: 'wechat', + input_days: 31, + extension_days: 60, + qr_content: localPaymentStatus === 'pending_payment' ? 'https://local.invalid/payment/7' : '', + expire_at: '2026-10-28T23:59:59+08:00', + before_deadline_at: '2026-10-27T23:59:59+08:00', + expected_deadline_at: '2026-12-26T23:59:59+08:00', + created_at: time, + approval_required: false +}) +const silencePolicy = { + id: 1, + name: '本地全部套餐政策', + scope_type: 'all', + scope_type_name: '全部套餐', + scope_ids: [], + unit_price: 100, + free_extension_limit: 1, + paid_max_months: 12, + single_max_months: null, + effective_from: time, + effective_to: null, + approval_exempt_types: [], + status: 1, + created_at: time, + updated_at: time +} +const silenceBatch = { + id: 3, + batch_no: 'LOCAL-BATCH-3', + path: 'special', + extension_type: 'free', + status: 'pending_approval', + approval_required: true, + approval_status: 'pending', + requested_count: 1, + succeeded_count: 0, + failed_count: 0, + reason: '本地原因', + basis: '本地依据', + amount: 0, + requested_at: time, + evidence_refs: [], + items: [ + { + id: 9, + row_no: 1, + package_usage_id: 4, + asset_identifier: '89860426012345678901', + package_name: '本地实名套餐', + extension_days: 30, + status: 'exception', + failure_code: 'local_exception', + failure_summary: '仅本地模拟执行异常', + amount: 0, + before_deadline_at: time + } + ] +} +const pkg = { + id: 8, + package_name: '本地实名套餐', + package_code: 'LOCAL', + package_type: 'formal', + series_id: 1, + series_name: '本地系列', + expiry_base: 'from_activation', + silence_period_days: 30, + silence_extension_enabled: true, + silence_config_required: true, + duration_months: 1, + calendar_type: 'natural_month', + status: 1, + shelf_status: 1, + cost_price: 100, + suggested_retail_price: 200, + real_data_mb: 1024, + is_gift: false, + created_at: time, + updated_at: time +} +const entries = [ + ['平台订单', '/order-management/platform-orders', 'orders:platform_page'], + ['代理商订单', '/order-management/agent-orders', 'orders:agent_page'], + ['在线支付订单', '/order-management/online-orders', 'orders:online_page'], + ['订单列表', '/order-management/order-list', ''], + ['订单详情', '/order-management/order-list/detail/:id', ''] +] +const page = (items, url) => ({ + items, + total: items.length, + page: Number(url.searchParams.get('page') || 1), + page_size: Number(url.searchParams.get('page_size') || 20), + size: Number(url.searchParams.get('page_size') || 20), + total_pages: 1 +}) +const ok = (res, data, code = 0, msg = 'local mock only') => { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ code, msg, data })) +} +export const mockApiPlugin = { + name: 'order-silence-known-contract-mock', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url, 'http://127.0.0.1'), + path = url.pathname + if (path === '/__order-silence-log') return ok(res, log) + const handled = + ['/api/auth/me', '/api/auth/login', '/api/auth/logout'].includes(path) || + /^\/api\/admin\/(orders|packages|package-series|package-silence|employee-collection-payment-methods|assets|system-configs|iot-cards|devices|shops|accounts)(\/|$)/.test( + path + ) + if (!handled) return next() + let raw = '' + for await (const part of req) raw += part + const input = raw ? JSON.parse(raw) : {}, + token = String(req.headers.authorization || ''), + type = token.includes('enterprise') + ? 4 + : token.includes('agent') + ? 3 + : token.includes('super') + ? 1 + : 2 + log.push({ + path, + method: req.method, + body: input, + query: Object.fromEntries(url.searchParams) + }) + const matches = (items, field, keyword) => + items.filter((item) => !keyword || String(item[field]).includes(keyword)) + if (path === '/api/admin/iot-cards/standalone') + return ok( + res, + page( + matches( + [ + { + id: 2, + iccid: '89860426012345678901', + virtual_no: 'LOCAL-CARD', + shop_name: '本地店铺' + } + ], + 'iccid', + url.searchParams.get('keyword') + ), + url + ) + ) + if (path === '/api/admin/devices') + return ok( + res, + page( + matches( + [ + { + id: 3, + virtual_no: 'LOCAL-DEVICE', + device_name: '本地设备', + imei: '860000000000001', + shop_name: '本地店铺' + } + ], + 'virtual_no', + url.searchParams.get('keyword') + ), + url + ) + ) + if (path === '/api/admin/shops') + return ok( + res, + page( + matches( + [{ id: 6, shop_name: '本地店铺' }], + 'shop_name', + url.searchParams.get('shop_name') + ), + url + ) + ) + if (path === '/api/admin/accounts') + return ok( + res, + page( + matches( + [{ id: 2, username: '本地操作员', shop_name: '本地店铺' }], + 'username', + url.searchParams.get('username') + ), + url + ) + ) + if (path.startsWith('/api/auth/')) + return ok(res, { + access_token: `local-silence-${type === 1 ? 'super' : type === 3 ? 'agent' : type === 4 ? 'enterprise' : 'platform'}`, + refresh_token: '', + expires_in: 3600, + user: { + id: type, + user_type: type, + username: '本地订单沉默期验收', + roles: [type === 1 ? 'R_SUPER' : 'R_ADMIN'] + }, + permissions: [ + 'orders:platform_page', + 'orders:agent_page', + 'orders:online_page', + 'package_silence:orders_page', + 'package_silence:order_detail_page', + 'package_silence:batches_page', + 'package_silence:batch_detail_page', + 'package_silence:usages_page', + 'package_silence:policies_page' + ], + buttons: [ + 'orders:add', + 'orders:export', + 'orders:view_detail', + 'package:add', + 'package:edit', + 'package:detail', + 'package_silence:override', + 'package_silence:extend', + 'package_silence:extend_batch', + 'package_silence:offline', + 'package_silence:exception', + 'package_silence:policy', + 'package_silence:order_detail', + 'package_silence:batch_detail', + 'package_silence:order_export', + 'package_silence:usage_export' + ], + menus: [ + { + name: '订单管理', + url: '/order-management', + children: entries.map(([name, url, perm_code]) => ({ name, url, perm_code })) + }, + { + name: '套餐管理', + url: '/package-management', + children: [ + { name: '套餐列表', url: '/package-management/package-list' }, + { name: '套餐详情', url: '/package-management/package-list/detail/:id' }, + ...['orders', 'batches', 'usages', 'policies'].map((kind, index) => ({ + name: ['延期订单', '延期批次与异常', '临期/过期套餐', '延期政策'][index], + url: `/package-management/silence-${kind}`, + perm_code: `package_silence:${kind}_page` + })), + { + name: '延期订单详情', + url: '/package-management/silence-orders/detail/:id', + perm_code: 'package_silence:order_detail_page', + is_hidden: true + }, + { + name: '延期批次详情', + url: '/package-management/silence-batches/detail/:id', + perm_code: 'package_silence:batch_detail_page', + is_hidden: true + } + ] + }, + { + name: '设置', + url: '/settings', + children: [{ name: '沉默期总开关', url: '/settings/package-silence-switch' }] + } + ] + }) + if (path.startsWith('/api/admin/orders')) { + if (type === 4) return ok(res, null, 403, '企业账号无权访问订单') + if (path.endsWith('/online') && type === 3) return ok(res, page([], url)) + const order = { + id: 1, + order_no: `LOCAL-${path.split('/').at(-1)}`, + order_type: 'single_card', + buyer_type: 'personal', + buyer_id: 1, + payment_status: 2, + payment_status_text: '已支付', + payment_method: 'offline', + total_amount: 200, + actual_paid_amount: 200, + paid_at: time, + items: [ + { + id: 1, + package_id: 8, + package_name: pkg.package_name, + quantity: 1, + unit_price: 200, + amount: 200 + } + ], + asset_identifier: '89860426012345678901', + asset_type: 'card', + created_at: time, + updated_at: time, + commission_status: 2, + commission_config_version: 1, + payments: [] + } + return ok( + res, + /\/orders\/\d+$/.test(path) || req.method === 'POST' ? order : page([order], url) + ) + } + if (path === '/api/admin/package-silence/switch') { + if (type !== 1) return ok(res, null, 403, '仅超级管理员可操作') + const precheck = [ + { code: 'package', name: '套餐沉默期配置', passed: false, count: 2 }, + { code: 'policy', name: '延期政策', passed: true, count: 1 }, + { code: 'template', name: '企微审批模板', passed: true, count: 1 }, + { code: 'member', name: '默认审批人', passed: true, count: 1 } + ] + return req.method === 'PUT' + ? ok(res, { failed_items: [precheck[0]] }, 1275, '检查未通过,存在未配置沉默期套餐') + : ok(res, { enabled: false, precheck }) + } + if (path === '/api/admin/employee-collection-payment-methods') + return ok(res, page([{ id: 2, name: '本地银行收款', enabled: true }], url)) + if (path.startsWith('/api/admin/assets/') && path.endsWith('/first-purchase-records')) + return ok(res, [ + { + id: 1, + generation: 1, + asset_identifier: 'LOCAL-ASSET', + first_purchase_at: time, + source: 'order', + source_name: '首次购买套餐', + order_no: 'LOCAL-ORDER', + end_reason: 'exchange_return', + ended_at: time, + created_at: time + } + ]) + if (path.startsWith('/api/admin/package-silence/')) { + if (path.endsWith('/eligibility')) + return ok(res, { + package_usage_id: 4, + can_extend: true, + special_extension_available: true, + free_extension_available: true, + min_days: 1, + max_days: 360, + min_months: 1, + max_months: 12, + unit_price: 100, + options: [{ amount: 100, billed_days: 30, months: 1, new_deadline: '2026-11-26' }], + original_deadline: '2026-10-27', + reason: '', + policy_id: 1, + policy_name: '本地全部套餐政策' + }) + if (path.endsWith('/quote')) + return ok(res, { + amount: 200, + billed_days: 60, + input_days: Number(url.searchParams.get('days')), + months: 2, + new_deadline: '2026-12-26', + valid: true + }) + if (path.endsWith('/orders/qrcode')) { + localPaymentStatus = 'pending_payment' + return ok(res, silenceOrder()) + } + if (path.endsWith('/orders/offline')) + return ok(res, { + ...silenceOrder(), + status: 'pending_approval', + status_name: '待审批', + approval_required: true, + qr_content: '', + payment_method: 'offline' + }) + if (path.endsWith('/orders')) return ok(res, page([silenceOrder()], url)) + if (/\/orders\/\d+$/.test(path)) { + const order = silenceOrder() + localPaymentStatus = 'completed' + return ok(res, order) + } + if (path.endsWith('/special')) return ok(res, silenceBatch) + if (path.endsWith('/batches')) return ok(res, page([silenceBatch], url)) + if (/\/batches\/\d+$/.test(path)) return ok(res, silenceBatch) + if (path.endsWith('/handle')) + return ok(res, { + ...silenceBatch.items[0], + status: input.action === 'close' ? 'closed' : 'completed', + failure_summary: '' + }) + if (path.endsWith('/usages')) + return ok( + res, + page( + [ + { + package_usage_id: 4, + package_id: 8, + package_name: pkg.package_name, + asset_id: 2, + asset_identifier: '89860426012345678901', + asset_type: 'iot_card', + shop_id: 1, + shop_name: '本地店铺', + created_at: time, + order_no: 'LOCAL-ORDER', + silence + } + ], + url + ) + ) + if (path.endsWith('/records')) + return ok(res, [ + { + id: 1, + event_type: 'set', + event_name: '设置沉默期', + before_status: null, + after_status: 'waiting', + after_deadline_at: time, + change_days: 30, + operator_id: 1, + operator_type: 'admin', + reason: '本地记录', + created_at: time + } + ]) + if (path.endsWith('/policies/match')) + return ok(res, { matched: true, package_id: 8, policy: silencePolicy }) + if (path.endsWith('/policies')) + return ok(res, req.method === 'GET' ? [silencePolicy] : { ...silencePolicy, ...input }) + if (/\/policies\/\d+(\/status)?$/.test(path)) return ok(res, { ...silencePolicy, ...input }) + return ok(res, null, 400, '本地未定义的沉默期接口已阻止') + } + if (path.startsWith('/api/admin/packages')) + return ok( + res, + path === '/api/admin/packages' && req.method === 'GET' + ? page([pkg], url) + : { ...pkg, ...input } + ) + if (path.startsWith('/api/admin/package-series')) + return ok( + res, + page([{ id: 1, series_name: '本地系列', series_code: 'LOCAL', status: 1 }], url) + ) + if (path.startsWith('/api/admin/system-configs')) + return ok(res, { list: [], total: 0, page: 1, page_size: 20 }) + return ok(res, null, 400, '本地未定义,已阻止请求') + }) + fallback.configureServer(server) + } +} diff --git a/scripts/tests/packageSilenceIntegration.test.mjs b/scripts/tests/packageSilenceIntegration.test.mjs new file mode 100644 index 0000000..534dd7f --- /dev/null +++ b/scripts/tests/packageSilenceIntegration.test.mjs @@ -0,0 +1,517 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' + +const read = (path) => readFileSync(path, 'utf8') +function evaluate(source, imports = {}, globals = {}) { + const code = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const m = { exports: {} } + new Function('require', 'module', 'exports', ...Object.keys(globals), code)( + (name) => { + if (name.endsWith('.vue')) return { default: {} } + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + m, + m.exports, + ...Object.values(globals) + ) + return m.exports +} +const helpers = evaluate(read('src/utils/business/packageSilence.ts')) +const constants = evaluate(read('src/config/constants/packageSilence.ts')) +const format = evaluate(read('src/utils/business/format.ts')) +const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format }) +const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway }) +const exportConfig = evaluate(read('src/config/constants/exportTask.ts')) + +test('silence business errors never count as successful orders, package saves or refunds', () => { + for (const code of [1260, 1261, 1262, 1268, 1275]) + assert.throws(() => helpers.assertSilenceResponse({ code, data: null }), { + message: helpers.SILENCE_ERROR_MESSAGES[code] + }) + assert.throws( + () => helpers.assertSilenceResponse({ code: 1266, msg: 'min_days=30,max_days=90' }), + { message: 'min_days=30,max_days=90' } + ) + const response = { code: 0, data: { warnings: ['saved'] } } + assert.equal(helpers.assertSilenceResponse(response), response) + for (const path of [ + 'src/views/order-management/order-list/index.vue', + 'src/views/package-management/package-list/index.vue', + 'src/views/asset-management/asset-information/components/dialogs/PackageRechargeDialog.vue', + 'src/components/business/CreateRefundDialog.vue' + ]) + assert.ok(read(path).includes('assertSilenceResponse')) +}) + +function setup(path, { type = 2, props = {}, permissions = [], overrides = {} } = {}) { + const calls = [], + unmount = [] + const user = vue.reactive({ + info: { id: 1, user_type: type }, + get isSuperAdmin() { + return Number(this.info.user_type) === 1 + }, + get getUserInfo() { + return this.info + } + }) + const services = new Proxy( + {}, + { + get: + (_, name) => + async (...args) => { + calls.push({ name, args }) + return overrides[name] + ? overrides[name](...args) + : { code: 0, data: { items: [], total: 0 } } + } + } + ) + const runtime = { + ...vue, + onMounted() {}, + onActivated() {}, + onDeactivated() {}, + onBeforeUnmount: (fn) => unmount.push(fn) + } + const store = { useUserStore: () => user } + const auth = { + useAuth: () => ({ hasAuth: (code) => user.isSuperAdmin || permissions.includes(code) }) + } + const scheduler = evaluate(read('src/composables/useSchedulerRequest.ts'), { + vue: runtime, + '@/store/modules/user': store, + '@/utils/business/gateway': gateway, + '@/utils/business/pollingScheduler': polling + }) + const checked = evaluate(read('src/composables/useCheckedColumns.ts'), { vue: runtime }) + const imports = { + vue: runtime, + pinia: { storeToRefs: vue.toRefs }, + 'vue-router': { useRouter: () => ({ push() {}, back() {} }) }, + 'element-plus': { + ElMessage: { success() {}, warning() {}, error() {} }, + ElMessageBox: { confirm: async () => {} }, + ElTag: {} + }, + '@/api/modules': { + OrderService: services, + ExportTaskService: services, + CardService: services, + DeviceService: services, + PackageManageService: services, + ShopService: services, + AssetService: services + }, + '@/api/modules/packageSilence': { PackageSilenceService: services }, + '@/store/modules/user': store, + '@/composables/useAuth': auth, + '@/composables/useCheckedColumns': checked, + '@/composables/useSchedulerRequest': scheduler, + '@/utils/business/format': format, + '@/utils/business/packageSilence': helpers, + '@/utils/business/pollingScheduler': polling, + '@/utils/business/gateway': gateway, + '@/utils/business/orderPayment': {}, + '@/utils/business': { hasVoucherKeys: (v) => !!v?.length, toVoucherKeyList: (v) => v }, + '@/router/routesAlias': { RoutesAlias: {} }, + '@/config/constants/audit': { AUDIT_PERMISSIONS: {} }, + '@/utils/business/auditNavigation': {}, + '@/components/business/audit/investigationController': {}, + '@/config/constants/bulkPurchase': { BULK_PURCHASE_PERMISSIONS: {} }, + '@/config/constants/packageSilence': constants, + '@/config/constants': exportConfig, + '@/config/constants/exportTask': exportConfig + } + const { descriptor } = parse(read(path)), + scope = vue.effectScope() + const component = evaluate( + compileScript(descriptor, { id: 'silence-integration' }).content, + imports, + runtime + ).default + const state = scope.run(() => component.setup(props, { expose() {}, emit() {} })) + return { + state, + calls, + user, + dispose() { + unmount.forEach((fn) => fn()) + scope.stop() + } + } +} + +test('order category role matrix and export scene boundaries match the document', () => { + for (const type of [1, 2, 3, 4, 0, undefined]) + for (const category of ['all', 'platform', 'agent', 'online']) { + const allowed = (category === 'online' ? [1, 2] : [1, 2, 3]).includes(type) + assert.equal(helpers.canAccessOrderCategory(type, category), allowed) + assert.equal( + exportConfig.canAccessExportTaskScene( + constants.ORDER_CATEGORY_CONFIG[category].scene, + type + ), + allowed + ) + } + assert.equal(helpers.canEditRestrictedAsset(4), false) + assert.equal(helpers.canEditRestrictedAsset(3), true) +}) +test('three category endpoints preserve query and shared legacy detail contract', async () => { + const calls = [] + class BaseService {} + for (const name of ['get', 'getOne']) + BaseService[name] = (url, params) => { + calls.push({ url, params }) + return Promise.resolve({ code: 0 }) + } + const { OrderService: S } = evaluate(read('src/api/modules/order.ts'), { + '../BaseService': { BaseService } + }) + const query = { page: 1, page_size: 20, is_expired: false, seller_shop_id: 8 } + await S.getPlatformOrders(query) + await S.getAgentOrders(query) + await S.getOnlineOrders(query) + await S.getOrders(query) + await S.getOrderById(1) + assert.deepEqual( + calls.map((x) => x.url), + [ + '/api/admin/orders/platform', + '/api/admin/orders/agent', + '/api/admin/orders/online', + '/api/admin/orders', + '/api/admin/orders/1' + ] + ) + for (const call of calls.slice(0, 4)) { + assert.deepEqual(call.params, query) + assert.equal('category' in call.params, false) + } +}) +for (const [category, method] of [ + ['platform', 'getPlatformOrders'], + ['agent', 'getAgentOrders'], + ['online', 'getOnlineOrders'], + ['all', 'getOrders'] +]) + test(`order view ${category} uses exact service and scene, blocks disallowed identities`, async () => { + for (const type of [1, 2, 3, 4]) { + const h = setup('src/views/order-management/order-list/index.vue', { + type, + props: { category } + }) + h.state.searchForm.is_expired = 0 + await h.state.getTableData() + const allowed = helpers.canAccessOrderCategory(type, category) + assert.equal(h.calls.length, allowed ? 1 : 0) + if (allowed) { + assert.equal(h.calls[0].name, method) + assert.equal(h.calls[0].args[0].is_expired, false) + assert.equal('category' in h.calls[0].args[0], false) + } + assert.equal( + h.state.categoryConfig.value.scene, + constants.ORDER_CATEGORY_CONFIG[category].scene + ) + h.dispose() + } + }) +test('package defaults enforce 1..90, nullable purchase defaults, addon omission and immutable activation base', () => { + for (const days of [0, 91, 1.5, NaN]) + assert.throws(() => + helpers.packageSilenceFields({ + package_type: 'formal', + expiry_base: 'from_activation', + silence_period_days: days + }) + ) + for (const days of [1, 30, 90]) + assert.equal( + helpers.packageSilenceFields({ + package_type: 'formal', + expiry_base: 'from_activation', + silence_period_days: days + }).silence_period_days, + days + ) + assert.throws(() => + helpers.packageSilenceFields({ + package_type: 'formal', + expiry_base: 'from_activation', + silence_period_days: null + }) + ) + assert.equal( + helpers.packageSilenceFields({ + package_type: 'formal', + expiry_base: 'from_purchase', + silence_period_days: null, + silence_extension_enabled: false + }).silence_extension_enabled, + false + ) + assert.deepEqual( + helpers.packageSilenceFields({ package_type: 'addon', silence_period_days: 999 }), + {} + ) + assert.throws(() => + helpers.packageSilenceFields( + { package_type: 'formal', expiry_base: 'from_purchase' }, + 'from_activation' + ) + ) +}) + +test('allocation overrides enforce source defaults before single, batch and series writes', async () => { + const packageData = { + package_type: 'formal', + expiry_base: 'from_purchase', + silence_period_days: null + } + let reads = 0 + const validation = evaluate(read('src/api/modules/packageSilenceValidation.ts'), { + './packageManage': { + PackageManageService: { + getPackageDetail: async () => { + reads++ + return { code: 0, data: { ...packageData } } + } + } + }, + '@/utils/business/packageSilence': helpers + }) + const writes = [] + class BaseService {} + for (const method of ['post', 'put', 'patch', 'create']) + BaseService[method] = (url, data) => { + writes.push({ url, data }) + return Promise.resolve({ code: 0, data }) + } + const imports = { '../BaseService': { BaseService }, './packageSilenceValidation': validation } + const S = evaluate( + read('src/api/modules/shopPackageAllocation.ts'), + imports + ).ShopPackageAllocationService + const G = evaluate(read('src/api/modules/shopSeriesGrant.ts'), imports).ShopSeriesGrantService + const calls = [ + () => S.createShopPackageAllocation({ package_id: 8, expiry_base_override: 'from_activation' }), + () => + S.createShopPackageBatchAllocations({ + package_ids: [8, 9], + expiry_base_override: 'from_activation' + }), + () => + S.updateShopPackageAllocationExpiryBase(1, { expiry_base_override: 'from_activation' }, 8), + () => + G.createShopSeriesGrant({ + packages: [{ package_id: 8 }], + expiry_base_override: 'from_activation' + }), + () => + G.manageGrantPackages(1, { + packages: [{ package_id: 8 }], + expiry_base_override: 'from_activation' + }) + ] + for (const call of calls) await assert.rejects(call, /先配置默认沉默期/) + assert.equal(writes.length, 0) + packageData.silence_period_days = 30 + for (const call of calls) await call() + assert.equal(writes.length, 5) + packageData.expiry_base = 'from_activation' + await assert.rejects( + () => S.updateShopPackageAllocationExpiryBase(1, { expiry_base_override: 'from_purchase' }, 8), + /不允许覆盖/ + ) + const previousReads = reads + await S.createShopPackageAllocation({ package_id: 8, expiry_base_override: null }) + await G.manageGrantPackages(1, { + packages: [{ package_id: 8, remove: true }], + expiry_base_override: 'from_purchase' + }) + assert.equal(reads, previousReads) +}) +test('purchase overrides do not impose a 90-day maximum and never leak unauthorized fields', () => { + assert.deepEqual(helpers.silencePurchaseFields(false, 91, 'why'), {}) + assert.deepEqual(helpers.silencePurchaseFields(true, null, 'why'), {}) + assert.deepEqual(helpers.silencePurchaseFields(true, 365, ' why '), { + silence_period_days: 365, + silence_reason: 'why' + }) + for (const days of [0, -1, 1.5, NaN]) + assert.throws(() => helpers.silencePurchaseFields(true, days, 'why')) + assert.throws(() => helpers.silencePurchaseFields(true, 30, ' ')) +}) + +test('purchase override remains null until explicitly enabled and disabling clears the reason', async () => { + const path = 'src/components/business/package-silence/SilencePurchaseFields.vue' + const { descriptor } = parse(read(path)) + const definition = evaluate(compileScript(descriptor, { id: 'optional-override' }).content, { + vue + }).default + let state + const days = vue.ref(null), + reason = vue.ref('') + const component = { + ...definition, + setup(props, context) { + state = definition.setup(props, context) + return () => null + } + } + const renderer = vue.createRenderer({ + createComment: () => ({}), + insert() {}, + remove() {}, + parentNode: () => null, + nextSibling: () => null + }) + const app = renderer.createApp({ + render: () => + vue.h(component, { + days: days.value, + reason: reason.value, + 'onUpdate:days': (v) => (days.value = v), + 'onUpdate:reason': (v) => (reason.value = v) + }) + }) + app.mount({}) + assert.equal(state.overrideEnabled.value, false) + assert.equal(days.value, null) + state.overrideEnabled.value = true + await vue.nextTick() + assert.equal(days.value, 1) + days.value = 365 + reason.value = 'why' + await vue.nextTick() + assert.equal(state.overrideEnabled.value, true) + state.overrideEnabled.value = false + await vue.nextTick() + assert.equal(days.value, null) + assert.equal(reason.value, '') + assert.match(descriptor.template.content, / { + const range = ['2026-10-01T00:00:00+08:00', '2026-10-28T23:59:59+08:00'] + assert.deepEqual(helpers.firstPurchaseRange(range), { + first_purchase_start: '2026-09-30T16:00:00Z', + first_purchase_end: '2026-10-28T15:59:59Z' + }) + assert.deepEqual(helpers.firstPurchaseRange([]), {}) + for (const value of [['bad', 'date'], range.toReversed(), [range[0]]]) + assert.throws(() => helpers.firstPurchaseRange(value)) +}) + +test('new order exports keep query.filters as an object, preserving zero/false and legacy serialization', () => { + for (const scene of ['order_platform', 'order_agent', 'order_online', 'order']) { + const filters = { + payment_status: 2, + is_expired: false, + seller_shop_id: 0, + start_time: '2026-10-01T00:00:00+08:00' + } + const h = setup('src/components/business/ExportTaskCreateDialog.vue', { + props: { scene, query: { filters }, modelValue: true, defaultFormat: 'xlsx' } + }) + const query = h.state.getNormalizedQuery() + assert.deepEqual(query.filters, scene === 'order' ? JSON.stringify(filters) : filters) + h.dispose() + } +}) +test('silence switch uses dedicated endpoints and boolean state, never generic system config', async () => { + const calls = [] + class BaseService {} + for (const method of ['get', 'put']) + BaseService[method] = (url, data) => { + calls.push({ method, url, data }) + return Promise.resolve({ code: 0 }) + } + const { PackageSilenceService: S } = evaluate(read('src/api/modules/packageSilence.ts'), { + '../BaseService': { BaseService } + }) + await S.getSwitch() + await S.setSwitch(false) + assert.deepEqual(calls, [ + { method: 'get', url: '/api/admin/package-silence/switch', data: undefined }, + { method: 'put', url: '/api/admin/package-silence/switch', data: { enabled: false } } + ]) +}) +test('silence switch refuses ordinary accounts and exposes failed prechecks from business errors', async () => { + for (const type of [2, 3, 4]) { + const h = setup('src/views/settings/package-silence-switch/index.vue', { type }) + await h.state.load() + await h.state.toggle() + assert.equal(h.calls.length, 0) + h.dispose() + } + const failed = [{ code: 'unconfigured', name: 'missing config', passed: false, count: 3 }] + const h = setup('src/views/settings/package-silence-switch/index.vue', { + type: 1, + overrides: { + getSwitch: () => ({ code: 0, data: { enabled: false, precheck: failed } }), + setSwitch: () => ({ code: 1275, msg: 'check failed', data: { failed_items: failed } }) + } + }) + await h.state.load() + await h.state.toggle() + assert.equal(h.state.data.value.enabled, false) + assert.deepEqual(h.state.failed.value, failed) + assert.equal(h.state.error.value, 'check failed') + await h.state.load() + assert.deepEqual(h.state.failed.value, []) + assert.equal(h.state.error.value, '') + h.dispose() +}) +test('all new and changed business templates compile, including conditional root order view', () => { + const paths = [ + 'src/views/order-management/order-list/index.vue', + 'src/views/package-management/package-list/index.vue', + 'src/views/settings/package-silence-switch/index.vue', + 'src/views/asset-management/asset-information/components/PackageListCard.vue', + 'src/views/asset-management/asset-information/components/CurrentPackageCard.vue', + 'src/components/business/package-silence/SilenceSummary.vue', + 'src/components/business/package-silence/SilencePurchaseFields.vue', + 'src/components/business/package-silence/PackageSilenceFields.vue' + ] + for (const path of paths) { + const { descriptor, errors } = parse(read(path)) + assert.deepEqual(errors, [], path) + const result = compileTemplate({ + source: descriptor.template.content, + filename: path, + id: path, + compilerOptions: { expressionPlugins: ['typescript'] } + }) + assert.deepEqual(result.errors, [], path) + } +}) +test('198 base regressions remain compatible while metadata and zero refunds follow the new contract', () => { + assert.equal(Object.keys(helpers.SILENCE_ERROR_MESSAGES).length, 16) + for (const type of ['override', 'extend', 'extend_batch', 'offline', 'exception', 'policy']) + assert.ok( + Object.values(constants.PACKAGE_SILENCE_PERMISSIONS).includes(`package_silence:${type}`) + ) + assert.match(read('src/types/api/wecom.ts'), /package_silence_approval/) + assert.match(read('src/views/settings/wecom/scenes/index.vue'), /沉默期审批/) + assert.match(read('src/views/finance/refund/index.vue'), /requested_refund_amount"\s+:min="0"/) + assert.match( + read('src/views/finance/refund/index.vue'), + /row.requested_refund_amount == null \? undefined : fenToYuan/ + ) + for (const path of [ + 'src/views/asset-management/iot-card-management/index.vue', + 'src/views/asset-management/device-list/index.vue' + ]) + assert.ok(read(path).includes('firstPurchaseRange')) +}) diff --git a/scripts/tests/pollingScheduler.test.mjs b/scripts/tests/pollingScheduler.test.mjs index 989c4d7..4c052b2 100644 --- a/scripts/tests/pollingScheduler.test.mjs +++ b/scripts/tests/pollingScheduler.test.mjs @@ -193,38 +193,229 @@ function mount( ...vue, onMounted: () => {}, onBeforeUnmount: (fn) => beforeUnmount.push(fn), - onDeactivated: (fn) => deactivated.push(fn) + onDeactivated: (fn) => deactivated.push(fn), + onActivated: () => {} } const { descriptor } = parse(source(path)) const script = compileScript(descriptor, { id: 'scheduler-test' }).content + const store = { useUserStore: () => user } + const schedulerRequest = evaluate(source('src/composables/useSchedulerRequest.ts'), { + vue: runtime, + '@/store/modules/user': store, + '@/utils/business/gateway': gateway, + '@/utils/business/pollingScheduler': helpers + }) const imports = { vue: runtime, - 'element-plus': { ElMessageBox: { confirm } }, + 'vue-router': { + useRoute: () => ({ params: { id: '1', carrier_id: '8' }, fullPath: '/local/detail/1' }), + useRouter: () => ({ back() {} }) + }, + 'element-plus': { ElMessageBox: { confirm }, ElMessage: { success() {} } }, '@/api/modules/pollingScheduler': { PollingSchedulerService: api }, '@/api/modules/storage': { StorageService: storage }, - '@/store/modules/user': { useUserStore: () => user }, + '@/store/modules/user': store, '@/composables/useAuth': { useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) }) }, '@/utils/business/gateway': gateway, '@/utils/business/pollingScheduler': helpers, '@/config/constants/pollingScheduler': constants, + '@/config/constants/gateway': evaluate(source('src/config/constants/gateway.ts')), + '@/composables/useSchedulerRequest': schedulerRequest, + '@/composables/useGateway': { + useGatewayCarriers: () => ({ options: vue.ref([]), search() {}, loading: vue.ref(false) }) + }, + '@/components/business/polling/SchedulerTable.vue': { default: {} }, + '@/components/business/gateway/StopResumeRecords.vue': { default: {} }, + './PollingStateDialog.vue': { default: {} }, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) } } - const state = evaluate(script, imports, runtime).default.setup(props, { - expose() {}, - emit: (...args) => events.push(args) - }) + const scope = vue.effectScope() + const state = scope.run(() => + evaluate(script, imports, runtime).default.setup(props, { + expose() {}, + emit: (...args) => events.push(args) + }) + ) return { state, calls, user, events, - dispose: () => beforeUnmount.forEach((fn) => fn()), + dispose: () => { + beforeUnmount.forEach((fn) => fn()) + scope.stop() + }, deactivate: () => deactivated.forEach((fn) => fn()) } } +const dryRunPath = 'src/views/polling-management/stop-resume-dry-run/index.vue' +const schedulerDetailPath = 'src/components/business/polling/SchedulerDetail.vue' +const dryRunResult = { + items: [{ card_id: 42, iccid: '89860426012345678901' }], + total: 1, + page: 3, + size: 20, + summary: [{ carrier_id: 8, count: 1 }], + totals: { total: 1, will_stop: 1, will_resume: 0, no_action: 0 } +} + +test('dry-run reset clears filters, old summary/details and errors without starting an unfiltered scan', async () => { + const h = mount(dryRunPath, { + permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page], + services: { dryRun: () => ok(dryRunResult) } + }) + Object.assign(h.state.filters, { + carrier_id: 8, + stop_reason: 'manual', + result: 'will_stop', + assume_control_on: [8], + assume_control_off: [9] + }) + h.state.page.value = 3 + await h.state.load() + assert.equal(h.state.data.value.totals.total, 1) + assert.equal(h.state.data.value.summary.length, 1) + assert.equal(h.state.data.value.items.length, 1) + h.state.error.value = 'old export error' + h.state.reset() + assert.deepEqual( + { ...h.state.filters }, + { + carrier_id: undefined, + stop_reason: '', + result: '', + assume_control_on: [], + assume_control_off: [] + } + ) + assert.equal(h.state.page.value, 1) + assert.equal(h.state.data.value, null) + assert.equal(h.state.error.value, '') + assert.equal(h.state.loading.value, false) + assert.equal(h.calls.length, 1) + h.dispose() +}) + +test('dry-run reset invalidates pending success/error and allows a fresh explicit query', async () => { + for (const fail of [false, true]) { + let resolve, reject + const pendingResponse = new Promise((done, failed) => { + resolve = done + reject = failed + }) + let requests = 0 + const h = mount(dryRunPath, { + permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page], + services: { dryRun: () => (++requests === 1 ? pendingResponse : ok(dryRunResult)) } + }) + h.state.filters.stop_reason = 'manual' + const pending = h.state.load() + assert.equal(h.state.loading.value, true) + h.state.reset() + if (fail) reject(new Error('stale request failed')) + else resolve(ok(dryRunResult)) + await pending + assert.equal(h.state.data.value, null) + assert.equal(h.state.error.value, '') + assert.equal(h.state.loading.value, false) + assert.equal(requests, 1) + h.state.filters.carrier_id = 9 + await h.state.load() + assert.equal(h.state.data.value.totals.total, 1) + assert.equal(h.calls.at(-1).args[0].carrier_id, 9) + assert.equal(h.calls.at(-1).args[0].stop_reason, undefined) + h.dispose() + } +}) + +for (const [kind, method] of [ + ['params', 'getCarrierParam'], + ['alerts', 'getAlert'], + ['imports', 'getImport'] +]) + test(`${kind} direct detail requires both page and detail permissions and retains the role boundary`, async () => { + const P = constants.SCHEDULER_PERMISSIONS[kind] + for (const permissions of [ + [], + [P.page], + [P.detailPage], + [P.detail], + [P.detailPage, P.detail] + ]) { + const allowed = permissions.includes(P.detailPage) && permissions.includes(P.detail) + const h = mount(schedulerDetailPath, { + props: { kind }, + permissions, + services: { [method]: () => ok({ id: 1, carrier_id: 8, card_id: 0, status: 3 }) } + }) + await h.state.load() + assert.equal(h.calls.length, allowed ? 1 : 0, permissions.join(',')) + assert.equal(!!h.state.data.value, allowed) + h.dispose() + } + for (const userType of [1, 3, 4]) { + const allowed = kind !== 'imports' || userType === 1 + const h = mount(schedulerDetailPath, { + props: { kind }, + userType, + permissions: [P.detailPage, P.detail], + services: { [method]: () => ok({ id: 1, status: 3 }) } + }) + await h.state.load() + assert.equal(h.calls.length, allowed ? 1 : 0) + assert.equal(!!h.state.data.value, allowed) + h.dispose() + } + }) + +test('revoking either detail permission clears displayed data and blocks subsequent refresh', async () => { + for (const kind of ['params', 'alerts', 'imports']) { + const P = constants.SCHEDULER_PERMISSIONS[kind] + for (const revoked of [P.detailPage, P.detail]) { + const permissions = vue.reactive([P.detailPage, P.detail]) + const h = mount(schedulerDetailPath, { props: { kind }, permissions }) + await h.state.load() + assert.ok(h.state.data.value) + permissions.splice(permissions.indexOf(revoked), 1) + assert.equal(h.state.data.value, null) + await h.state.load() + assert.equal(h.calls.length, 1) + h.dispose() + } + } +}) + +test('pending detail responses cannot appear after the detail permission is revoked', async () => { + for (const [kind, method] of [ + ['params', 'getCarrierParam'], + ['alerts', 'getAlert'], + ['imports', 'getImport'] + ]) { + const P = constants.SCHEDULER_PERMISSIONS[kind] + const permissions = vue.reactive([P.detailPage, P.detail]) + let resolve + const response = new Promise((done) => { + resolve = done + }) + const h = mount(schedulerDetailPath, { + props: { kind }, + permissions, + services: { [method]: () => response } + }) + const pending = h.state.load() + permissions.splice(permissions.indexOf(P.detail), 1) + resolve(ok({ id: 1, status: 3 })) + await pending + assert.equal(h.state.data.value, null) + assert.equal(h.state.loading.value, false) + assert.equal(h.calls.length, 1) + h.dispose() + } +}) + test('22 scheduler methods preserve exact HTTP paths, bodies, null and false', async () => { const calls = [] class BaseService {} diff --git a/scripts/tests/silenceExtension.test.mjs b/scripts/tests/silenceExtension.test.mjs new file mode 100644 index 0000000..30b2fcc --- /dev/null +++ b/scripts/tests/silenceExtension.test.mjs @@ -0,0 +1,701 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' + +const read = (path) => readFileSync(path, 'utf8') +function evaluate(source, imports = {}, globals = {}) { + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)( + (name) => { + if (name.endsWith('.vue')) return { default: {} } + assert.ok(imports[name], `Unexpected dependency ${name}`) + return imports[name] + }, + module, + module.exports, + ...Object.values(globals) + ) + return module.exports +} +const format = evaluate(read('src/utils/business/format.ts')) +const legacy = evaluate(read('src/utils/business/packageSilence.ts')) +const helpers = evaluate(read('src/utils/business/silenceExtension.ts'), { + './format': format, + './packageSilence': legacy +}) +const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format }) +const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway }) +const constants = evaluate(read('src/config/constants/packageSilence.ts')) +const response = (data) => ({ code: 0, data, msg: 'ok' }) +const flush = async () => { + await vue.nextTick() + await Promise.resolve() + await vue.nextTick() +} +const deferred = () => { + let resolve, reject + const promise = new Promise((a, b) => { + resolve = a + reject = b + }) + return { resolve, reject, promise } +} + +function setup( + name, + { + props = {}, + permissions = [], + overrides = {}, + confirm = async () => {}, + prompt = async () => ({ value: 'reason' }) + } = {} +) { + const calls = [], + hooks = [], + timers = new Set(), + events = [], + mounted = [] + const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true }) + const grants = vue.reactive(new Set(permissions)) + const route = vue.reactive({ + params: { id: '3' }, + fullPath: '/package-management/silence-orders/detail/3' + }) + const hasAuth = (code) => grants.has(code) + const services = new Proxy( + {}, + { + get: + (_, method) => + async (...args) => { + calls.push({ method, args }) + return overrides[method] + ? overrides[method](...args) + : response( + method === 'eligibility' + ? { + can_extend: true, + special_extension_available: true, + min_days: 1, + max_days: 720 + } + : method === 'quote' + ? { + valid: true, + amount: 100, + billed_days: 30, + input_days: args[1], + new_deadline: '2026-11-30' + } + : method === 'orders' + ? { items: [], total: 0 } + : [] + ) + } + } + ) + const runtime = { + ...vue, + onMounted: (fn) => mounted.push(fn), + onActivated() {}, + onDeactivated: (fn) => hooks.push(fn), + onBeforeUnmount: (fn) => hooks.push(fn) + } + const store = { useUserStore: () => user }, + auth = { useAuth: () => ({ hasAuth }) } + const scheduler = evaluate(read('src/composables/useSchedulerRequest.ts'), { + vue: runtime, + '@/store/modules/user': store, + '@/utils/business/gateway': gateway, + '@/utils/business/pollingScheduler': polling + }) + const imports = { + vue: runtime, + 'vue-router': { useRoute: () => route, useRouter: () => ({ push() {}, back() {} }) }, + '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1000) }) }, + 'qrcode.vue': { default: {} }, + 'element-plus': { ElMessageBox: { confirm, prompt } }, + '@/api/modules/packageSilence': { PackageSilenceService: services }, + '@/api/modules/employeeCollection': { EmployeeCollectionService: services }, + '@/api/modules/packageManage': { PackageManageService: services }, + '@/api/modules/packageSeries': { PackageSeriesService: services }, + '@/composables/useAuth': auth, + '@/store/modules/user': store, + '@/composables/useSchedulerRequest': scheduler, + '@/utils/business/pollingScheduler': polling, + '@/utils/business/silenceExtension': helpers, + '@/utils/business/gateway': gateway, + '@/utils/business/format': format, + '@/config/constants/packageSilence': constants + } + const { descriptor } = parse(read(`src/components/business/package-silence/${name}.vue`)) + const component = evaluate( + compileScript(descriptor, { id: 'silence-extension' }).content, + imports, + { + ...runtime, + setTimeout: (fn) => { + timers.add(fn) + return fn + }, + clearTimeout: (fn) => timers.delete(fn) + } + ).default + const scope = vue.effectScope(), + reactiveProps = vue.reactive(props) + const state = scope.run(() => + component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) }) + ) + return { + state, + calls, + user, + grants, + route, + props: reactiveProps, + timers, + events, + mount: async () => { + for (const fn of mounted) await fn() + }, + dispose() { + hooks.forEach((fn) => fn()) + scope.stop() + } + } +} +const extendPermission = constants.PACKAGE_SILENCE_PERMISSIONS.extend +async function ready(ctx) { + await ctx.state.loadEligibility() + ctx.state.days.value = 31 + await flush() + await ctx.state.loadQuote() + assert.equal(ctx.state.canSubmit.value, true) +} + +test('all 21 authoritative Apifox operations have exact service methods, including corrected first-purchase path and body IDs', async () => { + const calls = [] + class BaseService {} + for (const method of ['get', 'post', 'put']) + BaseService[method] = async (url, body) => { + calls.push({ method, url, body }) + return response({}) + } + const { PackageSilenceService: S } = evaluate(read('src/api/modules/packageSilence.ts'), { + '../BaseService': { BaseService } + }) + await S.firstPurchase('card', 9) + await S.batches({ has_exception: false }) + await S.batch(3) + await S.eligibility(4) + await S.handle(5, { id: 5, action: 'close', reason: 'r' }) + await S.orders({ payment_method: 'offline' }) + await S.order(6) + await S.createOffline({ days: 1 }) + await S.createQrcode({ days: 31 }) + await S.quote(4, 31) + await S.special({ items: [] }) + await S.policies({ status: 0 }) + await S.createPolicy({ name: 'n' }) + await S.policy(7) + await S.updatePolicy(7, { id: 99, name: 'edit' }) + await S.policyStatus(7, 0) + await S.match(4) + await S.records(4) + await S.getSwitch() + await S.setSwitch(false) + await S.usages({ view: 'expired' }) + const spec = JSON.parse(read('docs/api/package-silence.openapi.json')) + for (const [path, methods] of Object.entries(spec.paths)) + for (const method of Object.keys(methods)) { + const regex = new RegExp('^' + path.replace(/\{[^}]+\}/g, '[^/]+') + '$') + assert.ok( + calls.some((call) => call.method === method && regex.test(call.url)), + `${method} ${path}` + ) + } + assert.equal(calls.length, 21) + assert.equal(calls[0].url, '/api/admin/assets/card/9/first-purchase-records') + assert.deepEqual(calls[1].body, { has_exception: false }) + assert.deepEqual(calls[14].body, { id: 7, name: 'edit' }) + assert.deepEqual(calls[15].body, { id: 7, status: 0 }) +}) +test('money conversion is exact integer fen, keeps zero, rejects precision loss and never guesses absent values', () => { + for (const [value, expected] of [ + ['0', 0], + ['0.01', 1], + ['0.29', 29], + ['1.10', 110], + ['100', 10000] + ]) + assert.equal(helpers.silenceFen(value), expected) + for (const value of ['', null, '-1', '1.001', '1e2', 'NaN', '9007199254740992']) + assert.throws(() => helpers.silenceFen(value)) + assert.equal(helpers.silenceAmount(0), '¥0.00') + assert.equal(helpers.silenceAmount(undefined), '-') + assert.equal(helpers.silenceDisplay({ end_reason: 'exchange_return' }, 'end_reason'), '换货回库') +}) +test('CSV import uses only two documented item fields and rejects bad headers, duplicate and unsafe IDs', () => { + assert.deepEqual(helpers.parseSilenceCsv('\uFEFFpackage_usage_id,days\r\n11,31\r\n12,181'), [ + { package_usage_id: 11, days: 31 }, + { package_usage_id: 12, days: 181 } + ]) + for (const input of [ + 'id,days\n1,30', + 'package_usage_id,days', + 'package_usage_id,days\n1e4,30', + 'package_usage_id,days\n1,0', + 'package_usage_id,days\n1,30\n1,60' + ]) + assert.throws(() => helpers.parseSilenceCsv(input)) +}) +test('business failures retain per-row details, while pending approval is not an error or fabricated completion', () => { + const rows = [{ row_no: 2, reason: 'expired' }] + try { + helpers.silenceData({ code: 1267, data: { rows }, msg: '免费次数已用完' }) + } catch (e) { + assert.deepEqual(helpers.silenceRowsError(e), rows) + assert.equal(e.message, '免费次数已用完') + } + assert.deepEqual(helpers.silenceData(response({ status: 'pending_approval' })), { + status: 'pending_approval' + }) +}) +test('current quote is required and changing days invalidates it before any write', async () => { + const ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [extendPermission] + }) + try { + await ready(ctx) + ctx.state.days.value = 32 + await flush() + assert.equal(ctx.state.canSubmit.value, false) + await ctx.state.submit() + assert.equal( + ctx.calls.some((x) => x.method === 'createQrcode'), + false + ) + } finally { + ctx.dispose() + } +}) +test('online order sends days not client-calculated amount; QR status polling stops when closed', async () => { + const ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [extendPermission], + overrides: { + createQrcode: () => response({ id: 7, status: 'pending_payment', qr_content: 'local' }), + order: () => response({ id: 7, status: 'closed' }) + } + }) + try { + await ready(ctx) + await ctx.state.submit() + const call = ctx.calls.find((x) => x.method === 'createQrcode') + assert.equal(call.args[0].days, 31) + assert.equal('amount' in call.args[0], false) + assert.equal(typeof call.args[0].request_id, 'string') + assert.equal(ctx.timers.size, 1) + await ctx.state.refreshOrder() + assert.equal(ctx.timers.size, 0) + } finally { + ctx.dispose() + } +}) +test('retry after a transport failure reuses the same idempotency key instead of silently creating a new payment', async () => { + let count = 0 + const ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [extendPermission], + overrides: { + createQrcode: () => { + if (!count++) throw new Error('network') + return response({ id: 7, status: 'pending_approval' }) + } + } + }) + try { + await ready(ctx) + await ctx.state.submit() + await ctx.state.submit() + const calls = ctx.calls.filter((x) => x.method === 'createQrcode') + assert.equal(calls.length, 2) + assert.equal(calls[0].args[0].request_id, calls[1].args[0].request_id) + assert.equal(ctx.timers.size, 0) + } finally { + ctx.dispose() + } +}) +test('permission loss while confirmation is open prevents the payment write', async () => { + const confirm = deferred(), + ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [extendPermission], + confirm: () => confirm.promise + }) + try { + await ready(ctx) + const saving = ctx.state.submit() + await flush() + ctx.grants.clear() + await flush() + confirm.resolve() + await saving + assert.equal( + ctx.calls.some((x) => x.method === 'createQrcode'), + false + ) + } finally { + ctx.dispose() + } +}) +test('canceling confirmation sends no write; offline amount mismatch blocks even with a valid quote', async () => { + const ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [extendPermission, constants.PACKAGE_SILENCE_PERMISSIONS.offline], + confirm: async () => { + throw 'cancel' + } + }) + try { + await ready(ctx) + await ctx.state.submit() + assert.equal( + ctx.calls.some((x) => x.method === 'createQrcode'), + false + ) + ctx.state.mode.value = 'offline' + await flush() + await ctx.state.loadQuote() + Object.assign(ctx.state, {}) + ctx.state.methodId.value = 2 + ctx.state.tradeNo.value = 'T' + ctx.state.paidYuan.value = '0.99' + ctx.state.paidAt.value = '2026-10-28T10:00:00+08:00' + ctx.state.vouchers.value = ['a'] + await ctx.state.submit() + assert.match(ctx.state.error.value, /实收金额/) + assert.equal( + ctx.calls.some((x) => x.method === 'createOffline'), + false + ) + } finally { + ctx.dispose() + } +}) +test('late order creation after unmount cannot display a QR code or restart polling', async () => { + const pending = deferred(), + ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [extendPermission], + overrides: { createQrcode: () => pending.promise } + }) + await ready(ctx) + const saving = ctx.state.submit() + await flush() + ctx.dispose() + pending.resolve(response({ id: 7, status: 'pending_payment', qr_content: 'late' })) + await saving + assert.equal(ctx.state.order.value, undefined) + assert.equal(ctx.timers.size, 0) +}) +test('batch special extension requires both permissions and submits documented parsed items with zero amount', async () => { + const ctx = setup('SilenceExtendDialog', { + props: { batchMode: true }, + permissions: [extendPermission], + overrides: { special: () => response({ id: 2, status: 'pending_approval', items: [] }) } + }) + try { + ctx.state.batchItems.value = [ + { package_usage_id: 1, days: 121 }, + { package_usage_id: 2, days: 181 } + ] + ctx.state.reason.value = '原因' + ctx.state.basis.value = '依据' + await ctx.state.submit() + assert.equal( + ctx.calls.some((x) => x.method === 'special'), + false + ) + ctx.grants.add(constants.PACKAGE_SILENCE_PERMISSIONS.batch) + await flush() + await ctx.state.submit() + const call = ctx.calls.find((x) => x.method === 'special') + assert.deepEqual(call.args[0].items, [ + { package_usage_id: 1, days: 121 }, + { package_usage_id: 2, days: 181 } + ]) + assert.equal(call.args[0].amount, 0) + assert.equal('request_id' in call.args[0], false) + assert.equal(ctx.state.batch.value.status, 'pending_approval') + } finally { + ctx.dispose() + } +}) +test('read-only record dialogs use array responses and the exact corrected first-purchase URL contract', async () => { + const ctx = setup('SilenceRecordsDialog', { + props: { assetType: 'device', assetId: 2 }, + overrides: { firstPurchase: () => response([{ generation: 1, source: 'order' }]) } + }) + try { + await ctx.state.load() + assert.deepEqual(ctx.calls[0], { method: 'firstPurchase', args: ['device', 2] }) + assert.equal(ctx.state.data.value[0].generation, 1) + } finally { + ctx.dispose() + } +}) +test('direct order and batch details require page, hidden detail page and button permission together', async () => { + for (const kind of ['orders', 'batches']) { + const codes = constants.SILENCE_PAGES[kind] + for (const removed of ['page', 'detailPage', 'detail']) { + const ctx = setup('SilenceDetail', { + props: { kind }, + permissions: Object.entries(codes) + .filter(([key]) => key !== removed) + .map(([, v]) => v) + }) + try { + await ctx.state.load() + assert.equal(ctx.calls.length, 0) + assert.equal(ctx.state.allowed.value, false) + } finally { + ctx.dispose() + } + } + } +}) +test('order and usage exports freeze whitelisted list filters without pagination; policy zero status survives', async () => { + for (const kind of ['orders', 'usages', 'policies']) { + const codes = constants.SILENCE_PAGES[kind], + ctx = setup('SilenceManagement', { + props: { kind }, + permissions: [codes.page, codes.export, constants.PACKAGE_SILENCE_PERMISSIONS.policy] + }) + try { + ctx.state.filters.injected = 'never' + ctx.state.filters.status = 0 + if (kind === 'orders') + ctx.state.filters.time_range = ['2026-10-01T00:00:00+08:00', '2026-10-28T23:59:59+08:00'] + await ctx.state.load() + assert.equal('injected' in ctx.calls[0].args[0], false) + if (kind === 'policies') assert.equal(ctx.calls[0].args[0].status, 0) + else { + ctx.state.openExport() + assert.equal('page' in ctx.state.exportQuery.value.filters, false) + if (kind === 'orders') + assert.equal(ctx.state.exportQuery.value.filters.paid_start, '2026-09-30T16:00:00Z') + } + } finally { + ctx.dispose() + } + } +}) +test('searchable silence filters keep real IDs for list/export, clear stale record dependencies and reset all selections', async () => { + const ctx = setup('SilenceManagement', { + props: { kind: 'orders' }, + permissions: [constants.SILENCE_PAGES.orders.page, constants.SILENCE_PAGES.orders.export] + }) + try { + ctx.state.filters.asset_identifier = '89860001' + ctx.state.filters.package_id = 8 + ctx.state.filters.package_usage_id = 99 + await ctx.state.load() + assert.equal(ctx.calls.at(-1).args[0].package_id, 8) + assert.equal(ctx.calls.at(-1).args[0].package_usage_id, 99) + ctx.state.openExport() + assert.equal(ctx.state.exportQuery.value.filters.package_usage_id, 99) + ctx.state.filters.package_id = 9 + assert.equal(ctx.state.filters.package_usage_id, undefined) + ctx.state.filters.package_usage_id = 100 + ctx.state.filters.asset_identifier = '89860002' + assert.equal(ctx.state.filters.package_usage_id, undefined) + ctx.state.reset() + assert.deepEqual(ctx.state.filters, {}) + } finally { + ctx.dispose() + } +}) + +test('policy matching uses the selected package and rejects a late result after the selection dialog closes', async () => { + const pending = deferred() + const ctx = setup('SilenceManagement', { + props: { kind: 'policies' }, + permissions: [ + constants.SILENCE_PAGES.policies.page, + constants.PACKAGE_SILENCE_PERMISSIONS.policy + ], + overrides: { match: () => pending.promise } + }) + try { + await ctx.state.matchPolicy() + assert.equal(ctx.calls.length, 0) + ctx.state.matchVisible.value = true + ctx.state.matchPackage.value = 8 + const matching = ctx.state.matchPolicy() + assert.deepEqual(ctx.calls[0], { method: 'match', args: [8] }) + ctx.state.closeMatch() + pending.resolve(response({ matched: true, policy: { name: '不应弹出的旧结果' } })) + await matching + assert.equal(ctx.state.matchResult.value, undefined) + assert.equal(ctx.state.matching.value, false) + } finally { + ctx.dispose() + } +}) + +test('policy validation retains nullable limits and rejects missing scope and inverted dates', () => { + const value = { + name: '政策', + scope_type: 'all', + scope_ids: [], + effective_from: '2026-10-28T00:00:00+08:00', + effective_to: null, + unit_price: 0, + single_max_months: null + } + assert.doesNotThrow(() => helpers.validateSilencePolicy(value)) + assert.throws(() => helpers.validateSilencePolicy({ ...value, scope_type: 'package' })) + assert.throws(() => + helpers.validateSilencePolicy({ ...value, effective_to: '2026-10-27T00:00:00+08:00' }) + ) +}) +test('offline successful registration sends fen and receipt fields, and pending approval never starts QR polling', async () => { + const permission = constants.PACKAGE_SILENCE_PERMISSIONS.offline + const ctx = setup('SilenceExtendDialog', { + props: { usageId: 4 }, + permissions: [permission], + overrides: { + createOffline: () => response({ id: 7, status: 'pending_approval', approval_required: true }) + } + }) + try { + await ready(ctx) + ctx.state.methodId.value = 2 + ctx.state.tradeNo.value = 'LOCAL-TRADE' + ctx.state.paidYuan.value = '1.00' + ctx.state.paidAt.value = '2026-10-28T10:00:00+08:00' + ctx.state.vouchers.value = ['receipts/local.png'] + await ctx.state.submit() + const body = ctx.calls.find((x) => x.method === 'createOffline').args[0] + assert.equal(body.paid_amount, 100) + assert.equal(body.paid_at, '2026-10-28T02:00:00Z') + assert.equal(body.offline_payment_method_id, 2) + assert.deepEqual(body.payment_voucher_keys, ['receipts/local.png']) + assert.equal(ctx.state.order.value.status, 'pending_approval') + assert.equal(ctx.timers.size, 0) + } finally { + ctx.dispose() + } +}) +test('special transport uncertainty prevents duplicate non-idempotent submissions', async () => { + const ctx = setup('SilenceExtendDialog', { + props: { batchMode: true }, + permissions: [extendPermission, constants.PACKAGE_SILENCE_PERMISSIONS.batch], + overrides: { + special: () => { + throw new Error('timeout') + } + } + }) + try { + ctx.state.batchItems.value = [{ package_usage_id: 4, days: 31 }] + ctx.state.reason.value = 'reason' + ctx.state.basis.value = 'basis' + await ctx.state.submit() + await ctx.state.submit() + assert.equal(ctx.calls.filter((x) => x.method === 'special').length, 1) + assert.equal(ctx.state.specialUncertain.value, true) + assert.match(ctx.state.error.value, /批次列表|无权/) + } finally { + ctx.dispose() + } +}) +test('policy create and edit use full typed fields, fen, IDs and explicit nullable limits', async () => { + for (const id of [undefined, 4]) { + const ctx = setup('SilencePolicyDialog', { + props: { id, allowed: true }, + overrides: { + policy: () => + response({ + id: 4, + name: 'old', + scope_type: 'all', + scope_ids: [], + unit_price: 0, + status: 0, + effective_from: '2026-10-28T00:00:00+08:00', + single_max_months: null + }) + } + }) + try { + await ctx.mount() + ctx.state.form.name = 'new' + ctx.state.form.effective_from = '2026-10-28T00:00:00+08:00' + ctx.state.unitYuan.value = '0.29' + ctx.state.singleLimited.value = false + await ctx.state.submit() + const call = ctx.calls.find((x) => x.method === (id ? 'updatePolicy' : 'createPolicy')) + assert.ok(call) + const body = call.args[id ? 1 : 0] + assert.equal(body.unit_price, 29) + assert.equal(body.single_max_months, null) + assert.equal(body.effective_to, null) + assert.deepEqual(body.scope_ids, []) + if (id) { + assert.equal(body.id, 4) + assert.equal(body.status, 0) + } + } finally { + ctx.dispose() + } + } +}) +test('exception close uses the extension detail ID, requires a reason and both page/detail permissions', async () => { + const p = constants.SILENCE_PAGES.batches, + ctx = setup('SilenceDetail', { + props: { kind: 'batches' }, + permissions: [ + p.page, + p.detailPage, + p.detail, + constants.PACKAGE_SILENCE_PERMISSIONS.exception + ], + prompt: async () => ({ value: '关闭原因' }) + }) + try { + await ctx.state.handle({ id: 99 }, 'close') + const call = ctx.calls.find((x) => x.method === 'handle') + assert.deepEqual(call.args, [99, { id: 99, action: 'close', reason: '关闭原因' }]) + } finally { + ctx.dispose() + } +}) +test('all added Vue templates compile and route components resolve to actual files', () => { + const dir = 'src/components/business/package-silence' + for (const file of readdirSync(dir).filter((x) => x.endsWith('.vue'))) { + const filename = dir + '/' + file, + { descriptor } = parse(read(filename)) + const compiled = compileScript(descriptor, { id: file }) + const template = compileTemplate({ + source: descriptor.template.content, + filename, + id: file, + compilerOptions: { bindingMetadata: compiled.bindings } + }) + assert.deepEqual(template.errors, [], filename) + } + for (const kind of ['orders', 'batches', 'usages', 'policies']) + assert.ok( + read(`src/views/package-management/silence-${kind}/index.vue`).includes(`kind="${kind}"`) + ) +}) diff --git a/scripts/tests/verifyBuild.mjs b/scripts/tests/verifyBuild.mjs index 4854a1b..37af734 100644 --- a/scripts/tests/verifyBuild.mjs +++ b/scripts/tests/verifyBuild.mjs @@ -41,13 +41,15 @@ plugins.push( ) if (serve) { const { mockApiPlugin } = await import( - process.argv.includes('--scheduler') - ? './pollingSchedulerBrowserFixtures.mjs' - : process.argv.includes('--polling') - ? './pollingConfigBrowserFixtures.mjs' - : process.argv.includes('--gateway') - ? './gatewayBrowserFixtures.mjs' - : './octoberBrowserFixtures.mjs' + process.argv.includes('--order-silence') + ? './orderSilenceBrowserFixtures.mjs' + : process.argv.includes('--scheduler') + ? './pollingSchedulerBrowserFixtures.mjs' + : process.argv.includes('--polling') + ? './pollingConfigBrowserFixtures.mjs' + : process.argv.includes('--gateway') + ? './gatewayBrowserFixtures.mjs' + : './octoberBrowserFixtures.mjs' ) const server = await createServer({ ...loaded.config, diff --git a/src/api/modules/businessOptions.ts b/src/api/modules/businessOptions.ts new file mode 100644 index 0000000..5c135b0 --- /dev/null +++ b/src/api/modules/businessOptions.ts @@ -0,0 +1,177 @@ +import { PackageManageService } from './packageManage' +import { PackageSeriesService } from './packageSeries' +import { ShopService } from './shop' +import { CardService } from './card' +import { DeviceService } from './device' +import { AccountService } from './account' +import { PackageSilenceService } from './packageSilence' +import { gatewayData, gatewayError } from '@/utils/business/gateway' + +export type BusinessOptionKind = + | 'asset' + | 'card' + | 'package' + | 'series' + | 'shop' + | 'usage' + | 'account' +export interface BusinessOption { + value: string | number + label: string +} +export interface BusinessOptionPage { + options: BusinessOption[] + hasMore: boolean + warning?: string +} +export const BUSINESS_OPTION_LABELS: Record = { + asset: '资产', + card: '网卡', + package: '套餐', + series: '套餐系列', + shop: '店铺', + usage: '套餐记录', + account: '操作人' +} + +export async function searchBusinessOptions( + kind: BusinessOptionKind, + keyword: string, + page = 1, + context: { asset_identifier?: string; package_id?: number } = {} +): Promise { + const pagination = { page, page_size: 20 }, + name = keyword.trim() + const result = ( + data: { items?: unknown[] | null; total?: number }, + options: BusinessOption[] + ) => ({ + options, + hasMore: + typeof data.total === 'number' + ? page * pagination.page_size < data.total + : (data.items?.length || 0) >= pagination.page_size + }) + if (kind === 'package') { + const data = gatewayData( + await PackageManageService.getPackages({ ...pagination, package_name: name }) + ) + return result( + data, + (data.items || []).map((x) => ({ value: x.id, label: x.package_name })) + ) + } + if (kind === 'series') { + const data = gatewayData( + await PackageSeriesService.getPackageSeries({ ...pagination, series_name: name }) + ) + return result( + data, + (data.items || []).map((x) => ({ value: x.id, label: x.series_name })) + ) + } + if (kind === 'shop') { + const data = gatewayData(await ShopService.getShops({ ...pagination, shop_name: name })) + return result( + data, + (data.items || []).map((x) => ({ value: x.id, label: x.shop_name })) + ) + } + if (kind === 'account') { + const data = gatewayData(await AccountService.getAccounts({ ...pagination, username: name })) + return result( + data, + (data.items || []).flatMap((x) => { + const value = x.id ?? x.ID + return value == null + ? [] + : [{ value, label: [x.username, x.shop_name].filter(Boolean).join(' · ') }] + }) + ) + } + if (kind === 'usage') { + const data = gatewayData( + await PackageSilenceService.usages({ + ...pagination, + view: 'all', + ...context, + asset_identifier: name || context.asset_identifier + }) + ) + return result( + data, + (data.items || []).flatMap((x) => + x.package_usage_id == null + ? [] + : [ + { + value: x.package_usage_id, + label: [ + x.package_name || '未命名套餐', + x.asset_identifier, + x.order_no || x.created_at + ] + .filter(Boolean) + .join(' · ') + } + ] + ) + ) + } + const cards = async () => { + const data = gatewayData( + await CardService.getStandaloneIotCards({ ...pagination, keyword: name }) + ) + return result( + data, + (data.items || []) + .filter((x) => x.iccid) + .map((x) => ({ + value: kind === 'card' ? x.id : x.iccid, + label: [x.iccid, x.virtual_no, x.shop_name].filter(Boolean).join(' · ') + })) + ) + } + if (kind === 'card') return cards() + const devices = async () => { + const data = gatewayData(await DeviceService.getDevices({ ...pagination, keyword: name })) + return result( + data, + (data.items || []) + .filter((x) => x.virtual_no) + .map((x) => ({ + value: x.virtual_no, + label: [x.virtual_no, x.device_name, x.imei, x.shop_name].filter(Boolean).join(' · ') + })) + ) + } + const results = await Promise.allSettled([cards(), devices()]) + const pages = results.flatMap((x) => (x.status === 'fulfilled' ? [x.value] : [])) + const failures = results.flatMap((x, index) => + x.status === 'rejected' + ? [`${index === 0 ? '网卡' : '设备'}选项加载失败:${gatewayError(x.reason)}`] + : [] + ) + if (!pages.length) throw new Error(failures.join(';')) + return { + options: pages.flatMap((x) => x.options), + hasMore: pages.some((x) => x.hasMore), + ...(failures.length ? { warning: failures.join(';') } : {}) + } +} + +// 编辑已有政策时按详情补回名称,不用裸 ID 充当选项文案。 +export async function resolveBusinessOption( + kind: BusinessOptionKind, + value: number +): Promise { + if (kind === 'package') { + const data = gatewayData(await PackageManageService.getPackageDetail(value)) + return { value, label: data.package_name } + } + if (kind === 'series') { + const data = gatewayData(await PackageSeriesService.getPackageSeriesDetail(value)) + return { value, label: data.series_name } + } + throw new Error('不支持补全此选项') +} diff --git a/src/api/modules/order.ts b/src/api/modules/order.ts index cb60cd7..e9f02bd 100644 --- a/src/api/modules/order.ts +++ b/src/api/modules/order.ts @@ -13,6 +13,15 @@ import type { } from '@/types/api' export class OrderService extends BaseService { + static getPlatformOrders(params?: OrderQueryParams): Promise> { + return this.get('/api/admin/orders/platform', params) + } + static getAgentOrders(params?: OrderQueryParams): Promise> { + return this.get('/api/admin/orders/agent', params) + } + static getOnlineOrders(params?: OrderQueryParams): Promise> { + return this.get('/api/admin/orders/online', params) + } /** * 获取订单列表 * @param params 查询参数 diff --git a/src/api/modules/packageSilence.ts b/src/api/modules/packageSilence.ts new file mode 100644 index 0000000..49d7f1d --- /dev/null +++ b/src/api/modules/packageSilence.ts @@ -0,0 +1,116 @@ +import { BaseService } from '../BaseService' +import type { BaseResponse } from '@/types/api/common' +import type { SilenceSwitchState } from '@/types/api/packageSilence' +import type { + AssetFirstPurchaseRecordResponse, + SilenceBatchPageResult, + SilenceBatchResponse, + SilenceEligibilityResponse, + SilenceExceptionHandleParams, + SilenceExtensionResponse, + SilenceOrderPageResult, + SilenceExtensionOrderResponse, + SilenceOfflineOrderRequest, + SilenceOnlineOrderRequest, + SilenceQuoteResponse, + SilenceSpecialRequest, + SilencePolicyResponse, + SilencePolicyRequest, + SilencePolicyUpdateParams, + SilencePolicyMatchResponse, + SilenceRecordResponse, + SilenceUsagePageResult, + SilenceOrderQuery, + SilenceBatchQuery, + SilenceUsageQuery, + SilencePolicyQuery +} from '@/types/api/packageSilenceContract' + +const root = '/api/admin/package-silence' +const quiet = { requestOptions: { errorMessageMode: 'none' } } + +export class PackageSilenceService extends BaseService { + static eligibility(package_usage_id: number): Promise> { + return this.get(`${root}/eligibility`, { package_usage_id }, quiet) + } + static quote( + package_usage_id: number, + days: number + ): Promise> { + return this.get(`${root}/quote`, { package_usage_id, days }, quiet) + } + static createQrcode( + data: SilenceOnlineOrderRequest + ): Promise> { + return this.post(`${root}/orders/qrcode`, data, quiet) + } + static createOffline( + data: SilenceOfflineOrderRequest + ): Promise> { + return this.post(`${root}/orders/offline`, data, quiet) + } + static special(data: SilenceSpecialRequest): Promise> { + return this.post(`${root}/special`, data, quiet) + } + static orders(params: SilenceOrderQuery): Promise> { + return this.get(`${root}/orders`, params, quiet) + } + static order(id: number): Promise> { + return this.get(`${root}/orders/${id}`, undefined, quiet) + } + static batches(params: SilenceBatchQuery): Promise> { + return this.get(`${root}/batches`, params, quiet) + } + static batch(id: number): Promise> { + return this.get(`${root}/batches/${id}`, undefined, quiet) + } + static handle( + id: number, + data: SilenceExceptionHandleParams + ): Promise> { + return this.post(`${root}/extensions/${id}/handle`, data, quiet) + } + static usages(params: SilenceUsageQuery): Promise> { + return this.get(`${root}/usages`, params, quiet) + } + static policies(params: SilencePolicyQuery = {}): Promise> { + return this.get(`${root}/policies`, params, quiet) + } + static policy(id: number): Promise> { + return this.get(`${root}/policies/${id}`, undefined, quiet) + } + static createPolicy(data: SilencePolicyRequest): Promise> { + return this.post(`${root}/policies`, data, quiet) + } + static updatePolicy( + id: number, + data: SilencePolicyUpdateParams + ): Promise> { + return this.put(`${root}/policies/${id}`, { ...data, id }, quiet) + } + static policyStatus(id: number, status: number): Promise> { + return this.put(`${root}/policies/${id}/status`, { id, status }, quiet) + } + static match(package_id: number): Promise> { + return this.get(`${root}/policies/match`, { package_id }, quiet) + } + static records(package_usage_id: number): Promise> { + return this.get(`${root}/records`, { package_usage_id }, quiet) + } + static firstPurchase( + assetType: 'card' | 'device', + id: number + ): Promise> { + return this.get(`/api/admin/assets/${assetType}/${id}/first-purchase-records`, undefined, quiet) + } + static getSwitch(): Promise> { + return this.get('/api/admin/package-silence/switch') + } + static setSwitch(enabled: boolean): Promise> { + return this.put( + '/api/admin/package-silence/switch', + { enabled }, + { requestOptions: { errorMessageMode: 'none' } } + ) + } +} diff --git a/src/api/modules/packageSilenceValidation.ts b/src/api/modules/packageSilenceValidation.ts new file mode 100644 index 0000000..6f72b2e --- /dev/null +++ b/src/api/modules/packageSilenceValidation.ts @@ -0,0 +1,32 @@ +import { PackageManageService } from './packageManage' +import { + assertSilenceResponse, + validateSilenceExpiryOverride +} from '@/utils/business/packageSilence' + +// 复用既有套餐详情,不假设授权候选已携带新字段;全部校验完成后才写。 +export async function ensurePackageSilenceOverrides( + items: { packageId: number | undefined; override?: string | null }[] +) { + const checks = items.filter((item) => item.override && item.override !== 'default') + const packages = new Map< + number, + Awaited>['data'] + >() + for (let index = 0; index < checks.length; index += 4) { + await Promise.all( + checks.slice(index, index + 4).map(async (item) => { + if (!Number.isSafeInteger(item.packageId) || Number(item.packageId) < 1) + throw new Error('缺少套餐 ID,无法校验生效条件') + const id = item.packageId! + let pkg = packages.get(id) + if (!pkg) { + const response = assertSilenceResponse(await PackageManageService.getPackageDetail(id)) + pkg = response.data + packages.set(id, pkg) + } + validateSilenceExpiryOverride(pkg, item.override) + }) + ) + } +} diff --git a/src/api/modules/shopPackageAllocation.ts b/src/api/modules/shopPackageAllocation.ts index ebbaae1..db68c52 100644 --- a/src/api/modules/shopPackageAllocation.ts +++ b/src/api/modules/shopPackageAllocation.ts @@ -3,6 +3,7 @@ */ import { BaseService } from '../BaseService' +import { ensurePackageSilenceOverrides } from './packageSilenceValidation' import type { BaseResponse, CreateShopPackageBatchAllocationsRequest, @@ -17,9 +18,12 @@ export class ShopPackageAllocationService extends BaseService { * 创建店铺套餐分配 * POST /api/admin/shop-package-allocations */ - static createShopPackageAllocation( + static async createShopPackageAllocation( data: CreateShopPackageAllocationRequest ): Promise> { + await ensurePackageSilenceOverrides([ + { packageId: data.package_id, override: data.expiry_base_override } + ]) return this.post>( '/api/admin/shop-package-allocations', data @@ -30,9 +34,12 @@ export class ShopPackageAllocationService extends BaseService { * 批量创建店铺套餐分配 * POST /api/admin/shop-package-batch-allocations */ - static createShopPackageBatchAllocations( + static async createShopPackageBatchAllocations( data: CreateShopPackageBatchAllocationsRequest ): Promise> { + await ensurePackageSilenceOverrides( + data.package_ids.map((packageId) => ({ packageId, override: data.expiry_base_override })) + ) return this.post>( '/api/admin/shop-package-batch-allocations', data @@ -43,10 +50,12 @@ export class ShopPackageAllocationService extends BaseService { * 更新店铺套餐分配生效条件 * PATCH /api/admin/shop-package-allocations/{id}/expiry-base */ - static updateShopPackageAllocationExpiryBase( + static async updateShopPackageAllocationExpiryBase( id: number, - data: UpdateShopPackageAllocationExpiryBaseRequest + data: UpdateShopPackageAllocationExpiryBaseRequest, + packageId?: number ): Promise> { + await ensurePackageSilenceOverrides([{ packageId, override: data.expiry_base_override }]) return this.patch>( `/api/admin/shop-package-allocations/${id}/expiry-base`, data diff --git a/src/api/modules/shopSeriesGrant.ts b/src/api/modules/shopSeriesGrant.ts index 09e7134..8c8d0b9 100644 --- a/src/api/modules/shopSeriesGrant.ts +++ b/src/api/modules/shopSeriesGrant.ts @@ -3,6 +3,7 @@ */ import { BaseService } from '../BaseService' +import { ensurePackageSilenceOverrides } from './packageSilenceValidation' import type { ShopSeriesGrantResponse, ShopSeriesGrantQueryParams, @@ -31,9 +32,20 @@ export class ShopSeriesGrantService extends BaseService { * POST /api/admin/shop-series-grants * @param data 授权数据 */ - static createShopSeriesGrant( + static async createShopSeriesGrant( data: CreateShopSeriesGrantRequest ): Promise> { + await ensurePackageSilenceOverrides( + data.packages + .filter((item) => !item.remove) + .map((item) => ({ + packageId: item.package_id, + override: + item.expiry_base_override === undefined + ? data.expiry_base_override + : item.expiry_base_override + })) + ) return this.create('/api/admin/shop-series-grants', data) } @@ -88,10 +100,21 @@ export class ShopSeriesGrantService extends BaseService { * @param id 授权ID * @param data 套餐管理数据 */ - static manageGrantPackages( + static async manageGrantPackages( id: number, data: ManageGrantPackagesRequest ): Promise> { + await ensurePackageSilenceOverrides( + data.packages + .filter((item) => !item.remove) + .map((item) => ({ + packageId: item.package_id, + override: + item.expiry_base_override === undefined + ? data.expiry_base_override + : item.expiry_base_override + })) + ) return this.put>( `/api/admin/shop-series-grants/${id}/packages`, data diff --git a/src/components/business/BusinessEntitySelect.vue b/src/components/business/BusinessEntitySelect.vue new file mode 100644 index 0000000..9c804fd --- /dev/null +++ b/src/components/business/BusinessEntitySelect.vue @@ -0,0 +1,198 @@ + + + diff --git a/src/components/business/CreateRefundDialog.vue b/src/components/business/CreateRefundDialog.vue index 6cbca51..5b0f12f 100644 --- a/src/components/business/CreateRefundDialog.vue +++ b/src/components/business/CreateRefundDialog.vue @@ -117,6 +117,7 @@ diff --git a/src/components/business/package-silence/PackageSilenceFields.vue b/src/components/business/package-silence/PackageSilenceFields.vue new file mode 100644 index 0000000..778c983 --- /dev/null +++ b/src/components/business/package-silence/PackageSilenceFields.vue @@ -0,0 +1,18 @@ + + diff --git a/src/components/business/package-silence/SilenceDetail.vue b/src/components/business/package-silence/SilenceDetail.vue new file mode 100644 index 0000000..06ab7b0 --- /dev/null +++ b/src/components/business/package-silence/SilenceDetail.vue @@ -0,0 +1,284 @@ + + + diff --git a/src/components/business/package-silence/SilenceExtendDialog.vue b/src/components/business/package-silence/SilenceExtendDialog.vue new file mode 100644 index 0000000..332e59d --- /dev/null +++ b/src/components/business/package-silence/SilenceExtendDialog.vue @@ -0,0 +1,595 @@ + + + diff --git a/src/components/business/package-silence/SilenceManagement.vue b/src/components/business/package-silence/SilenceManagement.vue new file mode 100644 index 0000000..85714eb --- /dev/null +++ b/src/components/business/package-silence/SilenceManagement.vue @@ -0,0 +1,633 @@ + + diff --git a/src/components/business/package-silence/SilencePolicyDialog.vue b/src/components/business/package-silence/SilencePolicyDialog.vue new file mode 100644 index 0000000..2796bbf --- /dev/null +++ b/src/components/business/package-silence/SilencePolicyDialog.vue @@ -0,0 +1,246 @@ + + + diff --git a/src/components/business/package-silence/SilencePurchaseFields.vue b/src/components/business/package-silence/SilencePurchaseFields.vue new file mode 100644 index 0000000..188d125 --- /dev/null +++ b/src/components/business/package-silence/SilencePurchaseFields.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/business/package-silence/SilenceRecordsDialog.vue b/src/components/business/package-silence/SilenceRecordsDialog.vue new file mode 100644 index 0000000..60453eb --- /dev/null +++ b/src/components/business/package-silence/SilenceRecordsDialog.vue @@ -0,0 +1,66 @@ + + diff --git a/src/components/business/package-silence/SilenceSummary.vue b/src/components/business/package-silence/SilenceSummary.vue new file mode 100644 index 0000000..b44de6c --- /dev/null +++ b/src/components/business/package-silence/SilenceSummary.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/business/package-silence/SilenceTable.vue b/src/components/business/package-silence/SilenceTable.vue new file mode 100644 index 0000000..ae1567a --- /dev/null +++ b/src/components/business/package-silence/SilenceTable.vue @@ -0,0 +1,43 @@ + + diff --git a/src/components/business/polling/SchedulerDetail.vue b/src/components/business/polling/SchedulerDetail.vue index 9ad7a8c..72165e8 100644 --- a/src/components/business/polling/SchedulerDetail.vue +++ b/src/components/business/polling/SchedulerDetail.vue @@ -69,18 +69,21 @@ props.kind ] ) - const permission = () => SCHEDULER_PERMISSIONS[props.kind].detailPage - const { data, loading, error, load } = useSchedulerRequest>( - async () => { - const id = Number(route.params.id ?? route.params.carrier_id) - validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID') - if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id)) - if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id)) - return gatewayData(await Service.getImport(id)) - }, - () => - hasAuth(permission()) && (props.kind !== 'imports' || isPollingPlatform(user.info.user_type)) - ) + const canReadDetail = () => { + const permissions = SCHEDULER_PERMISSIONS[props.kind] + return ( + hasAuth(permissions.detailPage) && + hasAuth(permissions.detail) && + (props.kind !== 'imports' || isPollingPlatform(user.info.user_type)) + ) + } + const { data, loading, error, load } = useSchedulerRequest>(async () => { + const id = Number(route.params.id ?? route.params.carrier_id) + validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID') + if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id)) + if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id)) + return gatewayData(await Service.getImport(id)) + }, canReadDetail) const maps = { params: [ ['carrier_name', '运营商账户'], diff --git a/src/components/core/forms/art-search-bar/index.vue b/src/components/core/forms/art-search-bar/index.vue index f670db8..5b0ab1f 100644 --- a/src/components/core/forms/art-search-bar/index.vue +++ b/src/components/core/forms/art-search-bar/index.vue @@ -3,8 +3,9 @@ width.value < 500) - type FilterVo = string | number | undefined | null | unknown[] + type FilterVo = string | number | boolean | undefined | null | unknown[] interface PropsVO { filter: Record // 查询参数 @@ -113,15 +115,10 @@ const isShow = ref(false) - const useFormItemArr = computed(() => { - const isshowLess = !props.isExpand && !isShow.value - if (isshowLess) { - const num = Math.floor(24 / props.elColSpan) - 1 - return props.items.slice(0, num) - } else { - return props.items - } - }) + // 收起时保留控件实例,避免远程选中项的名称和候选缓存在重新展开后丢失。 + const visibleItemCount = computed(() => + !props.isExpand && !isShow.value ? Math.floor(24 / props.elColSpan) - 1 : props.items.length + ) const filter = computed({ get: () => props.filter, @@ -132,6 +129,7 @@ const componentsMap: Record = { input: ArtSearchInput, select: ArtSearchSelect, + 'business-select': ArtSearchBusinessSelect, 'tree-select': ArtSearchTreeSelect, radio: ArtSearchRadio, datetime: ArtSearchDate, diff --git a/src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue b/src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue new file mode 100644 index 0000000..b3b1c34 --- /dev/null +++ b/src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue @@ -0,0 +1,21 @@ + + diff --git a/src/composables/useSchedulerRequest.ts b/src/composables/useSchedulerRequest.ts index 36e9083..3300b36 100644 --- a/src/composables/useSchedulerRequest.ts +++ b/src/composables/useSchedulerRequest.ts @@ -52,5 +52,5 @@ export function useSchedulerRequest(request: () => Promise, allowed: () => active = false clear() }) - return { data, loading, error, load } + return { data, loading, error, load, clear } } diff --git a/src/config/constants/exportTask.ts b/src/config/constants/exportTask.ts index e5ebd9d..0209856 100644 --- a/src/config/constants/exportTask.ts +++ b/src/config/constants/exportTask.ts @@ -7,6 +7,12 @@ export interface ExportTaskScenePermissions { } export const EXPORT_TASK_DETAIL_PAGE_PERMISSION = 'export_task:detail_page' +export const canAccessExportTaskScene = (scene: ExportTaskScene | undefined, userType: unknown) => + scene === 'order_online' + ? [1, 2].includes(Number(userType)) + : ['order', 'order_platform', 'order_agent'].includes(scene || '') + ? [1, 2, 3].includes(Number(userType)) + : true export const isPollingPlatformExport = (scene?: ExportTaskScene) => !!scene && ['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run'].includes(scene) @@ -19,6 +25,56 @@ export interface ExportTaskSceneConfig { } export const EXPORT_TASK_SCENE_CONFIG: Record = { + package_silence_order: { + scene: 'package_silence_order', + sceneName: '沉默期延期订单', + pageTitle: '导出延期订单', + permissions: { + page: 'export_task:package_silence_order_page', + detail: 'export_task:package_silence_order_detail', + download: 'export_task:package_silence_order_download' + } + }, + package_silence_usage: { + scene: 'package_silence_usage', + sceneName: '沉默期套餐清单', + pageTitle: '导出沉默期套餐', + permissions: { + page: 'export_task:package_silence_usage_page', + detail: 'export_task:package_silence_usage_detail', + download: 'export_task:package_silence_usage_download' + } + }, + order_platform: { + scene: 'order_platform', + sceneName: '平台订单', + pageTitle: '导出平台订单', + permissions: { + page: 'export_task:order_platform_page', + detail: 'export_task:order_platform_detail', + download: 'export_task:order_platform_download' + } + }, + order_agent: { + scene: 'order_agent', + sceneName: '代理商订单', + pageTitle: '导出代理商订单', + permissions: { + page: 'export_task:order_agent_page', + detail: 'export_task:order_agent_detail', + download: 'export_task:order_agent_download' + } + }, + order_online: { + scene: 'order_online', + sceneName: '在线支付订单', + pageTitle: '导出在线支付订单', + permissions: { + page: 'export_task:order_online_page', + detail: 'export_task:order_online_detail', + download: 'export_task:order_online_download' + } + }, polling_pause: { scene: 'polling_pause', sceneName: '不轮询清单', diff --git a/src/config/constants/packageSilence.ts b/src/config/constants/packageSilence.ts new file mode 100644 index 0000000..1234f27 --- /dev/null +++ b/src/config/constants/packageSilence.ts @@ -0,0 +1,29 @@ +export const PACKAGE_SILENCE_PERMISSIONS = { + override: 'package_silence:override', + extend: 'package_silence:extend', + batch: 'package_silence:extend_batch', + offline: 'package_silence:offline', + exception: 'package_silence:exception', + policy: 'package_silence:policy' +} as const +export const SILENCE_PAGES = { + orders: { + page: 'package_silence:orders_page', + detail: 'package_silence:order_detail', + detailPage: 'package_silence:order_detail_page', + export: 'package_silence:order_export' + }, + batches: { + page: 'package_silence:batches_page', + detail: 'package_silence:batch_detail', + detailPage: 'package_silence:batch_detail_page' + }, + usages: { page: 'package_silence:usages_page', export: 'package_silence:usage_export' }, + policies: { page: 'package_silence:policies_page' } +} as const +export const ORDER_CATEGORY_CONFIG = { + all: { title: '订单列表', scene: 'order', page: '' }, + platform: { title: '平台订单', scene: 'order_platform', page: 'orders:platform_page' }, + agent: { title: '代理商订单', scene: 'order_agent', page: 'orders:agent_page' }, + online: { title: '在线支付订单', scene: 'order_online', page: 'orders:online_page' } +} as const diff --git a/src/router/guards/beforeEach.ts b/src/router/guards/beforeEach.ts index 287c210..3db0e48 100644 --- a/src/router/guards/beforeEach.ts +++ b/src/router/guards/beforeEach.ts @@ -275,7 +275,11 @@ async function processBackendMenu(router: Router): Promise { .map((menu) => convertBackendMenuToRoute(menu, routeMap)) .filter((route) => route !== null) - const finalMenuList = mergeDefaultMenus(menuList, routeMap) + const finalMenuList = filterMenuByRoles( + mergeDefaultMenus(menuList, routeMap), + userStore.info.roles || [], + Number(userStore.info.user_type) + ) await registerAndStoreMenu(router, finalMenuList, closeLoading) } catch (error) { closeLoading() diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index 648135e..70cca53 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -7,6 +7,7 @@ import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration' import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway' import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig' import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler' +import { SILENCE_PAGES, PACKAGE_SILENCE_PERMISSIONS } from '@/config/constants/packageSilence' import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools' import { EXPORT_TASK_DETAIL_PAGE_PERMISSION, @@ -165,6 +166,64 @@ export const asyncRoutes: AppRouteRecord[] = [ icon: '' }, children: [ + { + path: 'silence-orders', + name: 'SilenceOrders', + component: RoutesAlias.SilenceOrders, + meta: { title: '延期订单', keepAlive: true, permissions: [SILENCE_PAGES.orders.page] } + }, + { + path: 'silence-orders/detail/:id', + name: 'SilenceOrderDetail', + component: RoutesAlias.SilenceOrderDetail, + meta: { + title: '延期订单详情', + isHide: true, + keepAlive: false, + permissions: [SILENCE_PAGES.orders.detailPage] + } + }, + { + path: 'silence-batches', + name: 'SilenceBatches', + component: RoutesAlias.SilenceBatches, + meta: { + title: '延期批次与异常', + keepAlive: true, + permissions: [SILENCE_PAGES.batches.page] + } + }, + { + path: 'silence-batches/detail/:id', + name: 'SilenceBatchDetail', + component: RoutesAlias.SilenceBatchDetail, + meta: { + title: '延期批次详情', + isHide: true, + keepAlive: false, + permissions: [SILENCE_PAGES.batches.detailPage] + } + }, + { + path: 'silence-usages', + name: 'SilenceUsages', + component: RoutesAlias.SilenceUsages, + meta: { + title: '沉默期临期/过期套餐', + keepAlive: true, + permissions: [SILENCE_PAGES.usages.page] + } + }, + { + path: 'silence-policies', + name: 'SilencePolicies', + component: RoutesAlias.SilencePolicies, + meta: { + title: '延期政策', + keepAlive: true, + permissions: [SILENCE_PAGES.policies.page, PACKAGE_SILENCE_PERMISSIONS.policy] + } + }, // 套餐系列 { path: 'package-series', @@ -684,6 +743,42 @@ export const asyncRoutes: AppRouteRecord[] = [ keepAlive: true } }, + { + path: 'export-order-platform', + name: 'ExportPlatformOrder', + component: RoutesAlias.ExportPlatformOrder, + meta: { + title: '导出平台订单', + exportTaskScene: 'order_platform', + keepAlive: true, + allowedUserTypes: [1, 2, 3], + permissions: [EXPORT_TASK_SCENE_CONFIG.order_platform.permissions.page] + } + }, + { + path: 'export-order-agent', + name: 'ExportAgentOrder', + component: RoutesAlias.ExportAgentOrder, + meta: { + title: '导出代理商订单', + exportTaskScene: 'order_agent', + keepAlive: true, + allowedUserTypes: [1, 2, 3], + permissions: [EXPORT_TASK_SCENE_CONFIG.order_agent.permissions.page] + } + }, + { + path: 'export-order-online', + name: 'ExportOnlineOrder', + component: RoutesAlias.ExportOnlineOrder, + meta: { + title: '导出在线支付订单', + exportTaskScene: 'order_online', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: [EXPORT_TASK_SCENE_CONFIG.order_online.permissions.page] + } + }, { path: 'export-order', name: 'ExportOrderTaskList', @@ -835,6 +930,28 @@ export const asyncRoutes: AppRouteRecord[] = [ permissions: [EXPORT_TASK_SCENE_CONFIG.stop_resume_rule_dry_run.permissions.page] } }, + { + path: 'export-silence-order', + name: 'ExportSilenceOrder', + component: RoutesAlias.ExportSilenceOrder, + meta: { + title: '导出延期订单', + exportTaskScene: 'package_silence_order', + keepAlive: true, + permissions: [EXPORT_TASK_SCENE_CONFIG.package_silence_order.permissions.page] + } + }, + { + path: 'export-silence-usage', + name: 'ExportSilenceUsage', + component: RoutesAlias.ExportSilenceUsage, + meta: { + title: '导出沉默期套餐', + exportTaskScene: 'package_silence_usage', + keepAlive: true, + permissions: [EXPORT_TASK_SCENE_CONFIG.package_silence_usage.permissions.page] + } + }, { path: 'export-task-detail', name: 'ExportTaskDetail', @@ -856,9 +973,43 @@ export const asyncRoutes: AppRouteRecord[] = [ component: RoutesAlias.Home, meta: { title: 'menus.orderManagement.title', + allowedUserTypes: [1, 2, 3], icon: '' }, children: [ + { + path: 'platform-orders', + name: 'PlatformOrderList', + component: RoutesAlias.PlatformOrderList, + meta: { + title: '平台订单', + keepAlive: true, + allowedUserTypes: [1, 2, 3], + permissions: ['orders:platform_page'] + } + }, + { + path: 'agent-orders', + name: 'AgentOrderList', + component: RoutesAlias.AgentOrderList, + meta: { + title: '代理商订单', + keepAlive: true, + allowedUserTypes: [1, 2, 3], + permissions: ['orders:agent_page'] + } + }, + { + path: 'online-orders', + name: 'OnlineOrderList', + component: RoutesAlias.OnlineOrderList, + meta: { + title: '在线支付订单', + keepAlive: true, + allowedUserTypes: [1, 2], + permissions: ['orders:online_page'] + } + }, // 订单列表 { path: 'order-list', @@ -866,6 +1017,7 @@ export const asyncRoutes: AppRouteRecord[] = [ component: RoutesAlias.OrderList, meta: { title: 'menus.orderManagement.orderList', + allowedUserTypes: [1, 2, 3], keepAlive: true } }, @@ -876,6 +1028,7 @@ export const asyncRoutes: AppRouteRecord[] = [ component: RoutesAlias.OrderDetail, meta: { title: 'menus.orderManagement.orderDetail', + allowedUserTypes: [1, 2, 3], isHide: true, keepAlive: false } @@ -1202,6 +1355,12 @@ export const asyncRoutes: AppRouteRecord[] = [ roles: ['R_SUPER'] } }, + { + path: 'package-silence-switch', + name: 'PackageSilenceSwitch', + component: RoutesAlias.PackageSilenceSwitch, + meta: { title: '沉默期总开关', keepAlive: false, allowedUserTypes: [1] } + }, // 系统配置 { path: 'system-configs', diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 03ce2ae..891b686 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -98,6 +98,21 @@ export enum RoutesAlias { // 订单管理 OrderList = '/order-management/order-list', // 订单列表 + PackageSilenceSwitch = '/settings/package-silence-switch', + SilenceOrders = '/package-management/silence-orders', + SilenceOrderDetail = '/package-management/silence-orders/detail', + SilenceBatches = '/package-management/silence-batches', + SilenceBatchDetail = '/package-management/silence-batches/detail', + SilenceUsages = '/package-management/silence-usages', + SilencePolicies = '/package-management/silence-policies', + ExportSilenceOrder = '/asset-management/export-task-management/export-silence-order', + ExportSilenceUsage = '/asset-management/export-task-management/export-silence-usage', + PlatformOrderList = '/order-management/platform-orders', + AgentOrderList = '/order-management/agent-orders', + OnlineOrderList = '/order-management/online-orders', + ExportPlatformOrder = '/asset-management/export-task-management/export-order-platform', + ExportAgentOrder = '/asset-management/export-task-management/export-order-agent', + ExportOnlineOrder = '/asset-management/export-task-management/export-order-online', OrderDetail = '/order-management/order-list/detail', // 订单详情 // 财务管理 AgentRecharge = '/finance/agent-recharge', // 代理充值 diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index 8eb7c89..819dd31 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -157,6 +157,7 @@ export interface AssetExchangeTrace { * 对应接口:GET /api/admin/assets/resolve/:identifier */ export interface AssetResolveResponse { + first_purchase_at?: string | null asset_type: AssetType // 资产类型:card 或 device asset_id: number // 数据库 ID associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组) @@ -342,6 +343,7 @@ export type AssetRefreshResponse = AssetRealtimeStatusResponse * 对应接口:GET /api/admin/assets/:identifier/packages */ export interface AssetPackageUsageRecord { + silence?: import('./packageSilence').PackageSilenceInfo | null id?: number // 兼容前端当前映射字段 package_usage_id?: number // 套餐使用记录 ID package_id?: number // 套餐 ID diff --git a/src/types/api/card.ts b/src/types/api/card.ts index 685fca9..8af2d56 100644 --- a/src/types/api/card.ts +++ b/src/types/api/card.ts @@ -355,6 +355,8 @@ export enum StandaloneCardStatus { // 单卡查询参数 export interface StandaloneCardQueryParams extends PaginationParams { + first_purchase_start?: string + first_purchase_end?: string enable_polling?: boolean keyword?: string // 通用关键字搜索 has_active_package?: boolean // has active package filter @@ -382,6 +384,7 @@ export interface StandaloneCardQueryParams extends PaginationParams { // 单卡信息 export interface StandaloneIotCard { + first_purchase_at?: string | null gateway_card_imei?: string // 网关侧返回的卡 IMEI(可空) id: number // 卡ID iccid: string // ICCID diff --git a/src/types/api/device.ts b/src/types/api/device.ts index a9f98c6..16798fa 100644 --- a/src/types/api/device.ts +++ b/src/types/api/device.ts @@ -37,6 +37,7 @@ export type SwitchMode = '0' | '1' // 0=自动, 1=手动 // 设备信息 export interface Device { + first_purchase_at?: string | null enable_polling?: boolean id: number // 设备ID virtual_no: string // 虚拟号(原 device_no) @@ -84,6 +85,8 @@ export interface Device { // 设备查询参数 export interface DeviceQueryParams extends PaginationParams { + first_purchase_start?: string + first_purchase_end?: string enable_polling?: boolean has_active_package?: boolean // has active package filter real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名 diff --git a/src/types/api/exportTask.ts b/src/types/api/exportTask.ts index c18d096..db281dc 100644 --- a/src/types/api/exportTask.ts +++ b/src/types/api/exportTask.ts @@ -4,6 +4,11 @@ export type ExportTaskScene = | 'device' | 'iot_card' | 'order' + | 'order_platform' + | 'order_agent' + | 'order_online' + | 'package_silence_order' + | 'package_silence_usage' | 'package' | 'agent_wallet_transaction' | 'agent_recharge' @@ -77,7 +82,7 @@ export interface ExportTaskListResponse { export interface CreateExportTaskRequest { scene: ExportTaskScene format: ExportTaskFormat - query?: Record + query?: Record> } export interface CreateExportTaskResponse { diff --git a/src/types/api/order.ts b/src/types/api/order.ts index 65d214e..89cfe6c 100644 --- a/src/types/api/order.ts +++ b/src/types/api/order.ts @@ -155,6 +155,8 @@ export interface OrderListResponse { // 创建订单请求 export interface CreateOrderRequest { + silence_period_days?: number + silence_reason?: string order_type?: OrderType // 订单类型 iot_card_id?: number | null // IoT卡ID device_id?: number | null // 设备ID diff --git a/src/types/api/packageManagement.ts b/src/types/api/packageManagement.ts index b770916..edb219d 100644 --- a/src/types/api/packageManagement.ts +++ b/src/types/api/packageManagement.ts @@ -152,6 +152,9 @@ export interface ShopPackageBatchAllocationsResponse { * 套餐响应 */ export interface PackageResponse extends PackageAllocationExpiryBaseFields { + silence_period_days?: number | null + silence_extension_enabled?: boolean + silence_config_required?: boolean id: number package_code: string package_name: string @@ -190,6 +193,7 @@ export interface PackageResponse extends PackageAllocationExpiryBaseFields { * 套餐查询参数 */ export interface PackageQueryParams extends PaginationParams { + silence_unconfigured?: boolean package_name?: string // 套餐名称(模糊搜索) series_id?: number // 系列ID package_type?: string // 套餐类型 @@ -201,6 +205,8 @@ export interface PackageQueryParams extends PaginationParams { * 创建套餐请求 */ export interface CreatePackageRequest { + silence_period_days?: number | null + silence_extension_enabled?: boolean package_code: string // 套餐编码,必填 package_name: string // 套餐名称,必填 series_id?: number | null // 所属系列ID,可选 @@ -222,6 +228,8 @@ export interface CreatePackageRequest { * 更新套餐请求 */ export interface UpdatePackageRequest { + silence_period_days?: number | null + silence_extension_enabled?: boolean package_name?: string | null // 套餐名称,可选 series_id?: number | null // 所属系列ID,可选 package_type?: string | null // 套餐类型,可选 (formal:正式套餐, addon:附加套餐) diff --git a/src/types/api/packageSilence.ts b/src/types/api/packageSilence.ts new file mode 100644 index 0000000..fe1f2d2 --- /dev/null +++ b/src/types/api/packageSilence.ts @@ -0,0 +1,24 @@ +export interface PackageSilenceInfo { + silence_status: string + silence_status_name?: string + silence_deadline_at?: string | null + silence_start_at?: string | null + silence_period_days?: number | null + silence_extended_days?: number + silence_paid_extended_days?: number + silence_extension_count?: number + silence_free_extension_used?: number + silence_expired_at?: string | null + silence_adjust_pending?: boolean + extendable: boolean +} +export interface SilenceSwitchCheck { + code: string + name: string + passed: boolean + count: number +} +export interface SilenceSwitchState { + enabled: boolean + precheck: SilenceSwitchCheck[] +} diff --git a/src/types/api/packageSilenceContract.ts b/src/types/api/packageSilenceContract.ts new file mode 100644 index 0000000..2870206 --- /dev/null +++ b/src/types/api/packageSilenceContract.ts @@ -0,0 +1,667 @@ +// Generated from docs/api/package-silence.openapi.json (Apifox, 2026-10-28). +// Financial values use integer fen. Missing optional response fields are not fabricated. + +export interface AssetFirstPurchaseRecordResponse { + /** 资产 ID */ + asset_id?: number + /** 资产标识快照 */ + asset_identifier?: string + /** 资产类型 */ + asset_type?: string + /** 记录时间 */ + created_at?: string + /** 结束原因:exchange_return 换货回库 */ + end_reason?: string + /** 该世代结束时间 */ + ended_at?: string | null + /** 首次购买套餐时间 */ + first_purchase_at?: string + /** 世代 */ + generation?: number + /** 记录 ID */ + id?: number + /** 订单号 */ + order_no?: string + /** 来源:order 首次购买套餐 / backfill 历史回填 */ + source?: string + /** 来源名称 */ + source_name?: string +} + +export interface SilenceBatchPageResult { + /** 批次列表 */ + items?: Array + /** 页码 */ + page?: number + /** 每页数量 */ + page_size?: number + /** 总数 */ + total?: number +} + +export interface SilenceBatchResponse { + /** 费用(分) */ + amount?: number + /** 审批实例 ID */ + approval_instance_id?: number | null + /** 是否需要审批 */ + approval_required?: boolean + /** 审批状态 */ + approval_status?: string + /** 依据 */ + basis?: string + /** 批次号 */ + batch_no?: string + /** 完成时间 */ + completed_at?: string | null + /** 附件 */ + evidence_refs?: Array + /** 延期类型 */ + extension_type?: string + /** 失败数 */ + failed_count?: number + /** 批次 ID */ + id?: number + /** 明细 */ + items?: Array + /** 路径:online / offline / special / adjust */ + path?: string + /** 原因 */ + reason?: string + /** 发起时间 */ + requested_at?: string + /** 发起人 */ + requested_by?: number + /** 明细数 */ + requested_count?: number + /** 批次状态 */ + status?: string + /** 成功数 */ + succeeded_count?: number +} + +export interface SilenceExtensionResponse { + /** 延期后截止时间 */ + after_deadline_at?: string | null + /** 金额(分) */ + amount?: number + /** 资产标识 */ + asset_identifier?: string + /** 资产类型 */ + asset_type?: string + /** 批次 ID */ + batch_id?: number + /** 延期前截止时间 */ + before_deadline_at?: string | null + /** 执行时间 */ + executed_at?: string | null + /** 延长天数 */ + extension_days?: number + /** 延期订单 ID */ + extension_order_id?: number | null + /** 延期类型 */ + extension_type?: string + /** 失败 / 异常原因码 */ + failure_code?: string + /** 失败 / 异常说明 */ + failure_summary?: string + /** 明细 ID */ + id?: number + /** 输入天数 */ + input_days?: number + /** 计费月数 */ + months?: number + /** 套餐名称 */ + package_name?: string + /** 套餐记录 ID */ + package_usage_id?: number + /** 行号 */ + row_no?: number + /** 明细状态 */ + status?: string +} + +export interface SilenceEligibilityResponse { + /** 能否办理付费延期 */ + can_extend?: boolean + /** 已累计延期天数 */ + extended_days?: number + /** 套餐是否支持付费延期 */ + extension_enabled?: boolean + /** 是否还能申请免费延期 */ + free_extension_available?: boolean + /** 免费延期次数上限 */ + free_extension_limit?: number + /** 已使用免费延期次数 */ + free_extension_used?: number + /** 本次付费延期可输入的最多天数 */ + max_days?: number + /** 本次付费延期最多月数(特殊延期不受此限) */ + max_months?: number + /** 可输入的最少天数(付费与特殊延期都适用) */ + min_days?: number + /** 最少延期月数 */ + min_months?: number + /** min~max 每档计费天数、金额与新截止日期 */ + options?: Array + /** 当前截止日期(YYYY-MM-DD) */ + original_deadline?: string + /** 已过期天数 */ + overdue_days?: number + /** 套餐记录 ID */ + package_usage_id?: number + /** 其中付费延期累计天数 */ + paid_extended_days?: number + /** 未结束的延期订单号(有则不能新建,需先完成或关闭) */ + pending_order_no?: string + /** 当前匹配的延期政策 ID */ + policy_id?: number | null + /** 当前匹配的延期政策名称 */ + policy_name?: string + /** 付费延期不能办理的原因:not_expired/no_silence/extension_disabled/no_policy/exceeds_limit/refunded/order_pending/activated/adjust_pending */ + reason?: string + /** 沉默期状态(待实名但已到期的先判定为过期) */ + silence_status?: string + /** 能否申请特殊延期(只要求套餐已过期) */ + special_extension_available?: boolean + /** 每月单价(分),取当前匹配的延期政策 */ + unit_price?: number +} + +export interface SilenceExtensionOption { + /** 金额(分) */ + amount?: number + /** 计费 / 延长天数(月数 × 30) */ + billed_days?: number + /** 计费月数 */ + months?: number + /** 新截止日期(YYYY-MM-DD,当日 23:59:59 截止) */ + new_deadline?: string +} + +export interface SilenceExceptionHandleParams { + /** 处理方式:retry 重试执行 / close 关闭(在线订单关闭时原路退款) */ + action: string + /** 延期明细 ID */ + id: number + /** 处理原因 */ + reason: string +} + +export interface SilenceOrderPageResult { + /** 订单列表 */ + items?: Array + /** 页码 */ + page?: number + /** 每页数量 */ + page_size?: number + /** 总数 */ + total?: number +} + +export interface SilenceExtensionOrderResponse { + /** 应付金额(分) */ + amount?: number + /** 审批实例 ID */ + approval_instance_id?: number | null + /** 是否需要审批 */ + approval_required?: boolean + /** 资产 ID */ + asset_id?: number + /** 资产标识快照 */ + asset_identifier?: string + /** 资产类型 iot_card / device */ + asset_type?: string + /** 批次号 */ + batch_no?: string + /** 下单时截止时间 */ + before_deadline_at?: string + /** 延期生效时间 */ + completed_at?: string | null + /** 创建时间 */ + created_at?: string + /** 后台创建人 */ + created_by?: number | null + /** H5 下单的客户 */ + customer_id?: number | null + /** 申请依据 */ + evidence_refs?: Array + /** 异常原因码 */ + exception_code?: string + /** 异常说明 */ + exception_summary?: string + /** 预计新截止时间 */ + expected_deadline_at?: string + /** 支付截止时间 */ + expire_at?: string | null + /** 计费 / 延长天数 */ + extension_days?: number + /** 订单 ID */ + id?: number + /** 输入天数 */ + input_days?: number + /** 计费月数 */ + months?: number + /** 线下收款方式 */ + offline_payment_method_name?: string + /** 线下交易单号 */ + offline_trade_no?: string + /** 订单号 */ + order_no?: string + /** 套餐 ID */ + package_id?: number + /** 套餐名称快照 */ + package_name?: string + /** 套餐记录 ID */ + package_usage_id?: number + /** 实付金额(分) */ + paid_amount?: number | null + /** 支付时间 */ + paid_at?: string | null + /** 收款商户名称(取已支付或已退款支付单快照) */ + payment_merchant_name?: string + /** 支付方式:wechat/alipay/offline */ + payment_method?: string + /** 支付单号 */ + payment_no?: string + /** 线下收款凭证 */ + payment_voucher_keys?: Array + /** 政策名称快照 */ + policy_name?: string + /** 支付内容(待支付时返回,前端渲染为二维码或跳转链接) */ + qr_content?: string + /** 退款金额 */ + refund_amount?: number | null + /** 原路退款单号(仅执行异常) */ + refund_no?: string + /** 退款时间 */ + refunded_at?: string | null + /** 下单时资产所属店铺 */ + shop_id?: number + /** 来源:client / admin / offline */ + source?: string + /** 来源名称 */ + source_name?: string + /** 状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded */ + status?: string + /** 状态名称 */ + status_name?: string + /** 交易流水号 */ + third_party_trade_no?: string + /** 单价(分/月) */ + unit_price?: number +} + +export interface SilenceOfflineOrderRequest { + /** 输入的延期天数 */ + days: number + /** 客户申请依据 */ + evidence_refs?: Array | null + /** 线下收款方式(字典 ID) */ + offline_payment_method_id: number + /** 线下交易单号(同一收款方式下唯一) */ + offline_trade_no: string + /** 套餐记录 ID */ + package_usage_id: number + /** 实收金额(分),必须等于系统计算金额 */ + paid_amount?: number + /** 付款时间 */ + paid_at: string + /** 收款凭证对象存储 Key(1~5 个) */ + payment_voucher_keys: Array | null + /** 幂等键 */ + request_id: string +} + +export interface SilenceOnlineOrderRequest { + /** 输入的延期天数 */ + days: number + /** 客户申请记录或截图对象存储 Key(后台创建时填写) */ + evidence_refs?: Array | null + /** 套餐记录 ID */ + package_usage_id: number + /** 支付方式:wechat / alipay(经商户池路由) */ + payment_method: string + /** 下单幂等键 */ + request_id: string +} + +export interface SilenceQuoteResponse { + /** 金额(分) */ + amount?: number + /** 计费 / 延长天数 */ + billed_days?: number + /** 输入天数 */ + input_days?: number + /** 计费月数 */ + months?: number + /** 新截止日期 */ + new_deadline?: string + /** 是否在可办理范围内 */ + valid?: boolean +} + +export interface SilenceSpecialRequest { + /** 费用(分,免费为 0;优惠价为实收合计) */ + amount?: number + /** 免费或优惠依据 */ + basis?: string + /** 客户申请记录或截图 */ + evidence_refs?: Array | null + /** 延期类型:free 免费 / discount 优惠价 / complaint_compensation 客诉补偿 / system_compensation 系统异常补偿 / special_customer 特殊客户政策 */ + extension_type: string + /** 批量导入的明细文件 Key */ + import_file_key?: string + /** 明细(批量需 package_silence:extend_batch 权限) */ + items: Array | null + /** 优惠价有收款时:付款时间 */ + offline_paid_at?: string | null + /** 优惠价有收款时:线下收款方式 */ + offline_payment_method_id?: number | null + /** 优惠价有收款时:交易单号 */ + offline_trade_no?: string + /** 优惠价有收款时:收款凭证 */ + payment_voucher_keys?: Array | null + /** 原因 */ + reason: string +} + +export interface SilenceSpecialItem { + /** 延期天数(按 30 天向上取整) */ + days: number + /** 套餐记录 ID */ + package_usage_id: number +} + +export interface SilencePolicyResponse { + /** 免审批场景 */ + approval_exempt_types?: Array + /** 创建时间 */ + created_at?: string + /** 创建人 */ + creator?: number + /** 生效时间 */ + effective_from?: string + /** 失效时间 */ + effective_to?: string | null + /** 免费延期次数 */ + free_extension_limit?: number + /** 政策 ID */ + id?: number + /** 政策名称 */ + name?: string + /** 付费延期累计最多月数 */ + paid_max_months?: number + /** 备注 */ + remark?: string + /** 系列或套餐 ID 列表 */ + scope_ids?: Array + /** 适用范围 */ + scope_type?: string + /** 适用范围名称 */ + scope_type_name?: string + /** 单次付费延期最多月数 */ + single_max_months?: number | null + /** 状态:0-停用 1-启用 */ + status?: number + /** 每月单价(分) */ + unit_price?: number + /** 更新时间 */ + updated_at?: string + /** 更新人 */ + updater?: number +} + +export interface SilencePolicyRequest { + /** 免审批场景(多选,空表示全部需审批) */ + approval_exempt_types?: Array | null + /** 生效时间 */ + effective_from: string + /** 失效时间(为空表示长期) */ + effective_to?: string | null + /** 每笔套餐免费延期次数 */ + free_extension_limit?: number + /** 政策名称(唯一) */ + name: string + /** 付费延期累计最多月数 */ + paid_max_months?: number + /** 备注 */ + remark?: string + /** 系列 ID 或套餐 ID 列表(all 时为空) */ + scope_ids?: Array | null + /** 适用范围:all-全部套餐 series-套餐系列 package-指定套餐 */ + scope_type: string + /** 单次付费延期最多月数(为空表示不单独限制) */ + single_max_months?: number | null + /** 状态:0-停用 1-启用(新增默认启用) */ + status?: number | null + /** 每月单价(分),1 月 = 30 天 */ + unit_price?: number +} + +export interface SilencePolicyUpdateParams { + /** 免审批场景(多选,空表示全部需审批) */ + approval_exempt_types?: Array | null + /** 生效时间 */ + effective_from: string + /** 失效时间(为空表示长期) */ + effective_to?: string | null + /** 每笔套餐免费延期次数 */ + free_extension_limit?: number + /** 政策 ID */ + id: number + /** 政策名称(唯一) */ + name: string + /** 付费延期累计最多月数 */ + paid_max_months?: number + /** 备注 */ + remark?: string + /** 系列 ID 或套餐 ID 列表(all 时为空) */ + scope_ids?: Array | null + /** 适用范围:all-全部套餐 series-套餐系列 package-指定套餐 */ + scope_type: string + /** 单次付费延期最多月数(为空表示不单独限制) */ + single_max_months?: number | null + /** 状态:0-停用 1-启用(新增默认启用) */ + status?: number | null + /** 每月单价(分),1 月 = 30 天 */ + unit_price?: number +} + +export interface SilencePolicyStatusParams { + /** 政策 ID */ + id: number + /** 状态:0-停用 1-启用 */ + status: number | null +} + +export interface SilencePolicyMatchResponse { + /** 是否匹配到政策(未匹配时不能付费延期,特殊延期仍可申请) */ + matched?: boolean + /** 套餐 ID */ + package_id?: number + policy?: SilencePolicyResponse +} + +export interface SilenceRecordResponse { + /** 变更后截止时间 */ + after_deadline_at?: string | null + /** 变更后状态 */ + after_status?: string | null + /** 审批实例 */ + approval_instance_id?: number | null + /** 资产 ID */ + asset_id?: number + /** 资产类型 */ + asset_type?: string + /** 批次 */ + batch_id?: number | null + /** 变更前截止时间 */ + before_deadline_at?: string | null + /** 变更前状态 */ + before_status?: string | null + /** 变化天数 */ + change_days?: number + /** 时间 */ + created_at?: string + /** 默认值快照 */ + default_days?: number | null + /** 事件名称 */ + event_name?: string + /** 事件:set/adjust/expired/ended/extend/exchange/backfill/realname_backfill */ + event_type?: string + /** 换货单号 */ + exchange_no?: string + /** 延期明细 */ + extension_id?: number | null + /** 流水 ID */ + id?: number + /** 是否调整 */ + is_adjusted?: boolean + /** 操作人 */ + operator_id?: number + /** 操作人类型 */ + operator_type?: string + /** 套餐记录 ID */ + package_usage_id?: number + /** 沉默期天数 */ + period_days?: number | null + /** 原因 */ + reason?: string + /** 触发来源 */ + trigger_source?: string +} + +export interface SilenceSwitchResponse { + /** 总开关是否打开 */ + enabled?: boolean + /** 打开前检查各项结果 */ + precheck?: Array + /** 打开前检查是否全部通过 */ + precheck_passed?: boolean +} + +export interface SilenceSwitchPrecheckItem { + /** 检查项:unconfigured_packages / unbackfilled_usages / default_policy_missing / approval_scene_missing */ + code?: string + /** 数量(前两项为未满足的数量,后两项为已具备的数量) */ + count?: number + /** 检查项名称 */ + name?: string + /** 是否满足 */ + passed?: boolean +} + +export interface SilenceSwitchRequest { + /** 目标状态:true 打开 / false 关闭(应急关闭不做检查) */ + enabled: boolean | null +} + +export interface SilenceUsagePageResult { + /** 列表 */ + items?: Array + /** 页码 */ + page?: number + /** 每页数量 */ + page_size?: number + /** 总数 */ + total?: number +} + +export interface SilenceUsageItem { + /** 资产 ID */ + asset_id?: number + /** 资产标识 */ + asset_identifier?: string + /** 资产类型 */ + asset_type?: string + /** 购买时间 */ + created_at?: string + /** 订单号 */ + order_no?: string + /** 套餐 ID */ + package_id?: number + /** 套餐名称 */ + package_name?: string + /** 套餐记录 ID */ + package_usage_id?: number + /** 资产所属店铺 */ + shop_id?: number | null + /** 店铺名称 */ + shop_name?: string + silence?: PackageSilenceView +} + +export interface PackageSilenceView { + /** 是否显示延期入口(沉默期已过期的待生效套餐) */ + extendable?: boolean + /** 购买时调整沉默期的审批是否进行中 */ + silence_adjust_pending?: boolean + /** 当前沉默期截止时间(截止日 23:59:59) */ + silence_deadline_at?: string | null + /** 最近一次过期时间 */ + silence_expired_at?: string | null + /** 累计已延期天数 */ + silence_extended_days?: number + /** 累计延期次数 */ + silence_extension_count?: number + /** 已使用免费延期次数 */ + silence_free_extension_used?: number + /** 其中付费延期累计天数 */ + silence_paid_extended_days?: number + /** 沉默期天数 */ + silence_period_days?: number | null + /** 沉默期起算时间(购买支付成功时间) */ + silence_start_at?: string | null + /** 沉默期状态:waiting-待实名 expired-沉默期已过期 ended-沉默期已结束;待实名但已过截止的按 expired 返回 */ + silence_status?: string + /** 沉默期状态名称(待实名 / 延期待实名 / 沉默期已过期 / 沉默期已结束) */ + silence_status_name?: string +} + +export interface SilenceOrderQuery { + page?: number + page_size?: number + package_usage_id?: number | null + package_id?: number | null + asset_identifier?: string + order_no?: string + status?: string + source?: string + payment_method?: string + merchant_name?: string + third_party_trade_no?: string + paid_start?: string + paid_end?: string +} + +export interface SilenceBatchQuery { + page?: number + page_size?: number + batch_no?: string + path?: string + extension_type?: string + status?: string + has_exception?: boolean | null +} + +export interface SilenceUsageQuery { + page?: number + page_size?: number + view?: string + within_days?: number + asset_identifier?: string + package_id?: number | null + shop_id?: number | null + deadline_start?: string + deadline_end?: string +} + +export interface SilencePolicyQuery { + page?: number + page_size?: number + name?: string + scope_type?: string + status?: number | null + effective_at?: string +} diff --git a/src/types/api/wecom.ts b/src/types/api/wecom.ts index 3f132f7..16f649c 100644 --- a/src/types/api/wecom.ts +++ b/src/types/api/wecom.ts @@ -1,6 +1,7 @@ import type { BaseResponse, PaginationData, PaginationParams } from './common' export type WecomBusinessType = + | 'package_silence_approval' | 'refund_approval' | 'offline_recharge_approval' | 'employee_collection_approval' diff --git a/src/types/component/index.ts b/src/types/component/index.ts index fa47eff..f1b79ab 100644 --- a/src/types/component/index.ts +++ b/src/types/component/index.ts @@ -8,6 +8,7 @@ import { Option } from '../common' export type SearchComponentType = | 'input' | 'select' + | 'business-select' | 'tree-select' | 'radio' | 'checkbox' diff --git a/src/types/components.d.ts b/src/types/components.d.ts index bfe24b8..ee96384 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -51,6 +51,7 @@ declare module 'vue' { ArtScatterChart: typeof import('./../components/core/charts/ArtScatterChart.vue')['default'] ArtScreenLock: typeof import('./../components/core/layouts/art-screen-lock/index.vue')['default'] ArtSearchBar: typeof import('./../components/core/forms/art-search-bar/index.vue')['default'] + ArtSearchBusinessSelect: typeof import('./../components/core/forms/art-search-bar/widget/art-search-business-select/index.vue')['default'] ArtSearchDate: typeof import('./../components/core/forms/art-search-bar/widget/art-search-date/index.vue')['default'] ArtSearchInput: typeof import('./../components/core/forms/art-search-bar/widget/art-search-input/index.vue')['default'] ArtSearchRadio: typeof import('./../components/core/forms/art-search-bar/widget/art-search-radio/index.vue')['default'] @@ -78,6 +79,7 @@ declare module 'vue' { BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default'] BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default'] BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default'] + BusinessEntitySelect: typeof import('./../components/business/BusinessEntitySelect.vue')['default'] CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default'] CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default'] ChangePasswordDialog: typeof import('./../components/core/layouts/art-header-bar/ChangePasswordDialog.vue')['default'] @@ -95,11 +97,9 @@ declare module 'vue' { ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCalendar: typeof import('element-plus/es')['ElCalendar'] ElCard: typeof import('element-plus/es')['ElCard'] ElCascader: typeof import('element-plus/es')['ElCascader'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] - ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCol: typeof import('element-plus/es')['ElCol'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] @@ -127,19 +127,15 @@ declare module 'vue' { ElPopover: typeof import('element-plus/es')['ElPopover'] ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] - ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElResult: typeof import('element-plus/es')['ElResult'] ElRow: typeof import('element-plus/es')['ElRow'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] - ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] @@ -148,6 +144,7 @@ declare module 'vue' { ElUpload: typeof import('element-plus/es')['ElUpload'] ElWatermark: typeof import('element-plus/es')['ElWatermark'] ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default'] + FirstPurchaseCell: typeof import('./../components/business/package-silence/FirstPurchaseCell.vue')['default'] GatewayDynamicData: typeof import('./../components/business/gateway/GatewayDynamicData.vue')['default'] GatewayOperationTable: typeof import('./../components/business/gateway/GatewayOperationTable.vue')['default'] GatewayOptionsNotice: typeof import('./../components/business/gateway/GatewayOptionsNotice.vue')['default'] @@ -160,6 +157,7 @@ declare module 'vue' { NullableNumber: typeof import('./../components/business/polling/NullableNumber.vue')['default'] OperatorSelect: typeof import('./../components/business/OperatorSelect.vue')['default'] PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default'] + PackageSilenceFields: typeof import('./../components/business/package-silence/PackageSilenceFields.vue')['default'] PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default'] PollingBatchDialog: typeof import('./../components/business/polling/PollingBatchDialog.vue')['default'] PollingImportDialog: typeof import('./../components/business/polling/PollingImportDialog.vue')['default'] @@ -178,6 +176,14 @@ declare module 'vue' { SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default'] ShopCreditLimitDialog: typeof import('./../components/business/ShopCreditLimitDialog.vue')['default'] SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default'] + SilenceDetail: typeof import('./../components/business/package-silence/SilenceDetail.vue')['default'] + SilenceExtendDialog: typeof import('./../components/business/package-silence/SilenceExtendDialog.vue')['default'] + SilenceManagement: typeof import('./../components/business/package-silence/SilenceManagement.vue')['default'] + SilencePolicyDialog: typeof import('./../components/business/package-silence/SilencePolicyDialog.vue')['default'] + SilencePurchaseFields: typeof import('./../components/business/package-silence/SilencePurchaseFields.vue')['default'] + SilenceRecordsDialog: typeof import('./../components/business/package-silence/SilenceRecordsDialog.vue')['default'] + SilenceSummary: typeof import('./../components/business/package-silence/SilenceSummary.vue')['default'] + SilenceTable: typeof import('./../components/business/package-silence/SilenceTable.vue')['default'] SpeedLimitDialog: typeof import('./../components/device/SpeedLimitDialog.vue')['default'] StopResumeRecords: typeof import('./../components/business/gateway/StopResumeRecords.vue')['default'] SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default'] diff --git a/src/utils/business/apiError.ts b/src/utils/business/apiError.ts index bc15b3a..62d0ee0 100644 --- a/src/utils/business/apiError.ts +++ b/src/utils/business/apiError.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { SILENCE_ERROR_MESSAGES } from './packageSilence' export type ApiErrorKind = | 'auth' @@ -23,7 +24,8 @@ export const normalizeApiError = (error: unknown): NormalizedApiError => { const response = getResponse(error) const status = response?.status const code = response?.data?.code - const backendMessage = response?.data?.msg || response?.data?.message + const backendMessage = + response?.data?.msg || response?.data?.message || SILENCE_ERROR_MESSAGES[Number(code)] const timeout = axios.isAxiosError(error) && error.code === 'ECONNABORTED' if (timeout) { @@ -48,7 +50,7 @@ export const normalizeApiError = (error: unknown): NormalizedApiError => { kind: 'forbidden', status, code, - message: '没有执行此操作的权限', + message: SILENCE_ERROR_MESSAGES[Number(code)] ? backendMessage : '没有执行此操作的权限', unknownResult: false } if (status === 400) diff --git a/src/utils/business/packageSilence.ts b/src/utils/business/packageSilence.ts new file mode 100644 index 0000000..9fc8c2f --- /dev/null +++ b/src/utils/business/packageSilence.ts @@ -0,0 +1,114 @@ +import type { PackageSilenceInfo } from '@/types/api/packageSilence' + +export type OrderCategory = 'all' | 'platform' | 'agent' | 'online' +export function assertSilenceResponse( + response: T +): T { + if (response.code !== 0) + throw new Error( + response.msg || response.message || SILENCE_ERROR_MESSAGES[response.code] || '操作失败' + ) + return response +} +export const canAccessOrderCategory = (userType: unknown, category: OrderCategory = 'all') => + (category === 'online' ? [1, 2] : [1, 2, 3]).includes(Number(userType)) +export const canEditRestrictedAsset = (userType: unknown) => [1, 2, 3].includes(Number(userType)) + +export function validateSilenceExpiryOverride( + pkg: { + package_type: string + default_expiry_base?: string | null + expiry_base?: string | null + silence_period_days?: number | null + }, + override?: string | null +) { + if (!override || override === 'default' || pkg.package_type !== 'formal') return + const base = pkg.default_expiry_base || pkg.expiry_base + if (base === 'from_activation' && override === 'from_purchase') + throw new Error('实名即生效套餐不允许覆盖为购买即生效') + if ( + base === 'from_purchase' && + override === 'from_activation' && + (!Number.isInteger(pkg.silence_period_days) || + Number(pkg.silence_period_days) < 1 || + Number(pkg.silence_period_days) > 90) + ) + throw new Error('购买即生效套餐须先配置默认沉默期,才允许覆盖为实名即生效') + if (!base) throw new Error('无法确认套餐默认生效条件,请刷新套餐信息') +} + +export function packageSilenceFields( + value: { + package_type: string + expiry_base?: string | null + silence_config_required?: boolean + silence_period_days?: number | null + silence_extension_enabled?: boolean + }, + originalExpiryBase?: string | null +) { + if (value.package_type !== 'formal') return {} + if (originalExpiryBase === 'from_activation' && value.expiry_base === 'from_purchase') + throw new Error('实名即生效套餐不能改为购买即生效') + const days = value.silence_period_days + if (days == null && (value.expiry_base === 'from_activation' || value.silence_config_required)) + throw new Error('请配置默认沉默期(1–90 天)') + if (days != null && (!Number.isInteger(days) || days < 1 || days > 90)) + throw new Error('默认沉默期须为 1–90 天的整数') + return { + silence_period_days: days ?? null, + silence_extension_enabled: value.silence_extension_enabled ?? true + } +} + +export function silencePurchaseFields( + allowed: boolean, + days: number | null | undefined, + reason: string +) { + if (!allowed || days == null) return {} + if (!Number.isSafeInteger(days) || days < 1) throw new Error('本次沉默期须为大于等于 1 的整数') + if (!reason.trim()) throw new Error('填写本次沉默期时必须填写调整原因') + return { silence_period_days: days, silence_reason: reason.trim() } +} + +export function firstPurchaseRange(range: unknown) { + if (!Array.isArray(range) || !range.length) return {} + if (range.length !== 2 || range.some((value) => !value)) + throw new Error('请选择完整首次购买时间范围') + const dates = range.map((value) => new Date(value)) + if (dates.some((date) => !Number.isFinite(date.getTime())) || dates[0] > dates[1]) + throw new Error('首次购买时间范围无效') + return { + first_purchase_start: dates[0].toISOString().replace(/\.\d{3}Z$/, 'Z'), + first_purchase_end: dates[1].toISOString().replace(/\.\d{3}Z$/, 'Z') + } +} + +export function silenceStatusLabel(value: PackageSilenceInfo) { + return ( + value.silence_status_name || + ({ waiting: '待实名', expired: '沉默期已过期', ended: '沉默期已结束' }[value.silence_status] ?? + value.silence_status) + ) +} + +export const SILENCE_ERROR_MESSAGES: Record = { + 1260: '沉默期天数不在允许范围', + 1261: '无权限调整沉默期', + 1262: '不允许变更该套餐的生效条件', + 1263: '该套餐当前不满足延期条件', + 1264: '该套餐不支持付费延期,可申请特殊延期', + 1265: '超过可延期期限', + 1266: '延期天数不在允许范围', + 1267: '免费延期次数已用完', + 1268: '沉默期已过期的套餐不能退款', + 1269: '延期政策重叠', + 1270: '未匹配到延期政策', + 1271: '已有未结束的延期订单或申请', + 1272: '线下交易单号已登记', + 1273: '实收金额与计算金额不一致', + 1274: '沉默期功能未开启', + 1275: '沉默期开启前检查未通过' +} diff --git a/src/utils/business/silenceExtension.ts b/src/utils/business/silenceExtension.ts new file mode 100644 index 0000000..cb85913 --- /dev/null +++ b/src/utils/business/silenceExtension.ts @@ -0,0 +1,164 @@ +import type { BaseResponse } from '@/types/api/common' +import type { SilenceSpecialItem, SilencePolicyRequest } from '@/types/api/packageSilenceContract' +import { formatDateTime } from './format' +import { SILENCE_ERROR_MESSAGES } from './packageSilence' + +export function silenceData(response: BaseResponse): T { + if (response.code !== 0) { + const error = new Error( + response.msg || response.message || SILENCE_ERROR_MESSAGES[response.code] || '请求失败' + ) + throw Object.assign(error, { details: response.data, code: response.code }) + } + return response.data +} +export function silenceRowsError( + error: unknown +): { row_no?: number; row?: number; reason?: string; message?: string }[] { + const value = error as { + details?: { rows?: unknown[] } + response?: { data?: { data?: { rows?: unknown[] } } } + } + const rows = value?.details?.rows || value?.response?.data?.data?.rows + return Array.isArray(rows) + ? rows.filter( + (row): row is { row_no?: number; row?: number; reason?: string; message?: string } => + !!row && typeof row === 'object' + ) + : [] +} +export function silenceId(value: unknown, label = '记录 ID'): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) + throw new Error(`${label}无效`) + return value +} +export function silenceDays(value: unknown): number { + return silenceId(value, '延期天数') +} +export function silenceFen(value: unknown): number { + const text = String(value ?? '').trim() + if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error('金额须为非负数,最多两位小数') + const [integer, decimal = ''] = text.split('.') + const amount = Number(integer) * 100 + Number(decimal.padEnd(2, '0')) + if (!Number.isSafeInteger(amount)) throw new Error('金额超出允许范围') + return amount +} +export function silenceAmount(value: unknown): string { + return typeof value === 'number' && Number.isFinite(value) ? `¥${(value / 100).toFixed(2)}` : '-' +} +export function silenceDate(value: unknown): string { + const date = new Date(String(value ?? '')) + if (!Number.isFinite(date.getTime())) throw new Error('请选择有效时间') + return date.toISOString().replace(/\.\d{3}Z$/, 'Z') +} +export function silenceRange(value: unknown, start: string, end: string): Record { + if (!Array.isArray(value) || !value.length) return {} + if (value.length !== 2 || value.some((item) => !item)) throw new Error('请选择完整时间范围') + const a = silenceDate(value[0]), + b = silenceDate(value[1]) + if (a > b) throw new Error('结束时间不能早于开始时间') + return { [start]: a, [end]: b } +} +export const SILENCE_ORDER_STATUS = { + pending_payment: '待支付', + pending_approval: '待审批', + rejected: '已驳回', + paid: '已支付待生效', + completed: '已完成', + closed: '已关闭', + exception: '执行异常', + refunded: '已退款' +} as const +export const SILENCE_SPECIAL_TYPES = { + free: '免费延期', + discount: '优惠价延期', + complaint_compensation: '客诉补偿', + system_compensation: '系统异常补偿', + special_customer: '特殊客户政策' +} as const +export const silenceOptions = (map: Record) => + Object.entries(map).map(([value, label]) => ({ value, label })) +export function silenceDisplay(row: Record, key: string): string { + const value = row[key] + if (key === 'status_name') + return String( + value || + SILENCE_ORDER_STATUS[row.status as keyof typeof SILENCE_ORDER_STATUS] || + row.status || + '-' + ) + if (key === 'source_name') + return String( + value || + ( + { + order: '首次购买套餐', + backfill: '历史回填', + client: '客户端', + admin: '后台', + offline: '线下' + } as Record + )[String(row.source)] || + row.source || + '-' + ) + if (key === 'end_reason' && value === 'exchange_return') return '换货回库' + if (key === 'status' && typeof value === 'number') + return value === 1 ? '启用' : value === 0 ? '停用' : String(value) + if (value == null || value === '') return '-' + if (['amount', 'paid_amount', 'refund_amount', 'unit_price'].includes(key)) + return silenceAmount(value) + if (/(?:_at|effective_from|effective_to|requested_at)$/.test(key)) + return formatDateTime(String(value)) + if (key === 'status') + return String( + row.status_name || SILENCE_ORDER_STATUS[value as keyof typeof SILENCE_ORDER_STATUS] || value + ) + if (key === 'extension_type') + return SILENCE_SPECIAL_TYPES[value as keyof typeof SILENCE_SPECIAL_TYPES] || String(value) + if (typeof value === 'boolean') return value ? '是' : '否' + if (Array.isArray(value)) return value.join('、') || '-' + return String(value) +} +export function parseSilenceCsv(text: string): SilenceSpecialItem[] { + // 两列均为纯整数;不接受有歧义的 CSV/Excel 科学计数法或错误表头。 + const lines = text + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter((line) => line.trim()) + if (lines.shift()?.trim() !== 'package_usage_id,days') + throw new Error('CSV 表头须为 package_usage_id,days') + if (!lines.length) throw new Error('CSV 没有明细') + const seen = new Set() + return lines.map((line, index) => { + if (!/^\s*\d+\s*,\s*\d+\s*$/.test(line)) + throw new Error(`第 ${index + 2} 行须为套餐记录 ID、延期天数两个整数`) + const [id, days] = line.split(',').map(Number) + silenceId(id, `第 ${index + 2} 行套餐记录 ID`) + silenceDays(days) + if (seen.has(id)) throw new Error(`第 ${index + 2} 行套餐记录重复`) + seen.add(id) + return { package_usage_id: id, days } + }) +} +export function validateSilencePolicy(value: SilencePolicyRequest) { + if (!value.name?.trim()) throw new Error('请填写政策名称') + if (!['all', 'series', 'package'].includes(value.scope_type)) throw new Error('请选择适用范围') + if (value.scope_type !== 'all' && !value.scope_ids?.length) + throw new Error('请选择适用系列或套餐') + value.scope_ids?.forEach((id) => silenceId(id, '适用范围 ID')) + const from = silenceDate(value.effective_from) + if (value.effective_to && silenceDate(value.effective_to) <= from) + throw new Error('失效时间必须晚于生效时间') + for (const key of [ + 'unit_price', + 'free_extension_limit', + 'paid_max_months', + 'single_max_months' + ] as const) { + const n = value[key] + if (n != null && (!Number.isSafeInteger(n) || n < 0)) + throw new Error('金额、次数和月数须为非负整数') + } +} diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 04d0d5d..f96ae8f 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -559,7 +559,10 @@ {{ getBindingStopButtonText(bindingCard) }} + {{ cardInfo.first_purchase_at ? formatDateTime(cardInfo.first_purchase_at) : '-' + }}首次购买套餐记录 + @@ -700,7 +725,7 @@ diff --git a/src/views/asset-management/asset-information/components/CurrentPackageCard.vue b/src/views/asset-management/asset-information/components/CurrentPackageCard.vue index 40679e4..5530281 100644 --- a/src/views/asset-management/asset-information/components/CurrentPackageCard.vue +++ b/src/views/asset-management/asset-information/components/CurrentPackageCard.vue @@ -25,6 +25,11 @@ diff --git a/src/views/asset-management/export-task-management/export-order-online/index.vue b/src/views/asset-management/export-task-management/export-order-online/index.vue new file mode 100644 index 0000000..da33525 --- /dev/null +++ b/src/views/asset-management/export-task-management/export-order-online/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/asset-management/export-task-management/export-order-platform/index.vue b/src/views/asset-management/export-task-management/export-order-platform/index.vue new file mode 100644 index 0000000..83e9562 --- /dev/null +++ b/src/views/asset-management/export-task-management/export-order-platform/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/asset-management/export-task-management/export-silence-order/index.vue b/src/views/asset-management/export-task-management/export-silence-order/index.vue new file mode 100644 index 0000000..45c33da --- /dev/null +++ b/src/views/asset-management/export-task-management/export-silence-order/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/asset-management/export-task-management/export-silence-usage/index.vue b/src/views/asset-management/export-task-management/export-silence-usage/index.vue new file mode 100644 index 0000000..a93adde --- /dev/null +++ b/src/views/asset-management/export-task-management/export-silence-usage/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/asset-management/export-task-management/export-task-detail/index.vue b/src/views/asset-management/export-task-management/export-task-detail/index.vue index 92ca07e..e4486cb 100644 --- a/src/views/asset-management/export-task-management/export-task-detail/index.vue +++ b/src/views/asset-management/export-task-management/export-task-detail/index.vue @@ -48,6 +48,7 @@ } from '@/config/constants' import { useUserStore } from '@/store/modules/user' import { isPollingPlatform } from '@/utils/business/pollingScheduler' + import { canAccessExportTaskScene } from '@/config/constants/exportTask' import { formatDateTime } from '@/utils/business/format' import type { ExportTaskDetail } from '@/types/api' import { ExportTaskStatus } from '@/types/api' @@ -70,6 +71,8 @@ throw error } if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败') + if (!canAccessExportTaskScene(res.data.scene, user.info.user_type)) + throw Object.assign(new Error('当前账号无权访问该类订单导出'), { status: 403 }) if (isPollingPlatformExport(res.data.scene) && !isPollingPlatform(user.info.user_type)) throw Object.assign(new Error('仅平台账号可查看该导出任务'), { status: 403 }) return res.data @@ -84,6 +87,7 @@ const canViewDetail = computed( () => !!taskPermissions.value && + canAccessExportTaskScene(taskDetail.value?.scene, user.info.user_type) && hasAuth(taskPermissions.value.detail) && (!isPollingPlatformExport(taskDetail.value?.scene) || isPollingPlatform(user.info.user_type)) ) diff --git a/src/views/asset-management/export-task-management/export-task-list/index.vue b/src/views/asset-management/export-task-management/export-task-list/index.vue index dbe3ed7..6e32cf1 100644 --- a/src/views/asset-management/export-task-management/export-task-list/index.vue +++ b/src/views/asset-management/export-task-management/export-task-list/index.vue @@ -49,6 +49,7 @@ import { getExportTaskSceneConfig, isPollingPlatformExport } from '@/config/constants' import { useUserStore } from '@/store/modules/user' import { isPollingPlatform } from '@/utils/business/pollingScheduler' + import { canAccessExportTaskScene } from '@/config/constants/exportTask' import type { SearchFormItem } from '@/types' import type { ExportTaskDetail, ExportTaskItem, ExportTaskScene } from '@/types/api' import { ExportTaskStatus } from '@/types/api' @@ -61,7 +62,8 @@ const { hasAuth } = useAuth() const user = useUserStore() const allowedScene = (scene: ExportTaskScene) => - !isPollingPlatformExport(scene) || isPollingPlatform(user.info.user_type) + canAccessExportTaskScene(scene, user.info.user_type) && + (!isPollingPlatformExport(scene) || isPollingPlatform(user.info.user_type)) const loading = ref(false) const taskList = ref([]) const defaultSceneConfig = getExportTaskSceneConfig('device')! diff --git a/src/views/asset-management/iot-card-management/index.vue b/src/views/asset-management/iot-card-management/index.vue index a02e3fd..4be298d 100644 --- a/src/views/asset-management/iot-card-management/index.vue +++ b/src/views/asset-management/iot-card-management/index.vue @@ -1075,6 +1075,8 @@ import PollingBatchDialog from '@/components/business/polling/PollingBatchDialog.vue' import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler' import { canPollingBatch, pollingScope } from '@/utils/business/pollingScheduler' + import { firstPurchaseRange } from '@/utils/business/packageSilence' + import FirstPurchaseCell from '@/components/business/package-silence/FirstPurchaseCell.vue' import { useUserStore } from '@/store/modules/user' import { storeToRefs } from 'pinia' import { formatDateTime } from '@/utils/business/format' @@ -1614,6 +1616,7 @@ [key: string]: any } = { enable_polling: undefined, + first_purchase_range: [] as string[], status: undefined, network_status: undefined, real_name_status: undefined, @@ -1976,6 +1979,18 @@ }) const formItems: SearchFormItem[] = baseFormItems + formItems.push({ + label: '首次购买起止', + prop: 'first_purchase_range', + type: 'datetimerange', + config: { + rangeSeparator: '至', + startPlaceholder: '开始时间', + endPlaceholder: '结束时间', + valueFormat: 'YYYY-MM-DDTHH:mm:ssZ', + clearable: true + } + }) formItems.push({ label: '是否轮询', prop: 'enable_polling', @@ -1989,6 +2004,7 @@ // 列配置 const columnOptions = [ + { label: '首次购买套餐时间', prop: 'first_purchase_at' }, { label: 'ICCID', prop: 'iccid' }, { label: 'IMEI', prop: 'gateway_card_imei' }, { label: 'MSISDN', prop: 'msisdn' }, @@ -2110,6 +2126,13 @@ // 动态列配置 const { columnChecks, columns } = useCheckedColumns(() => [ + { + prop: 'first_purchase_at', + label: '首次购买套餐时间', + width: 180, + formatter: (row: StandaloneIotCard) => + h(FirstPurchaseCell, { assetId: row.id, assetType: 'card', time: row.first_purchase_at }) + }, { prop: 'iccid', label: 'ICCID', @@ -2423,10 +2446,10 @@ Object.entries(formFilters).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return if (Array.isArray(value) && value.length === 0) return - query[key] = value + if (key !== 'first_purchase_range') query[key] = value }) - return query + return { ...query, ...firstPurchaseRange(formFilters.first_purchase_range) } }) const showExportDialog = () => { @@ -2444,8 +2467,10 @@ const params: Record = { page: pagination.page, page_size: pagination.pageSize, - ...formFilters + ...formFilters, + ...firstPurchaseRange(formFilters.first_purchase_range) } + delete params.first_purchase_range Object.keys(params).forEach((key) => { if (params[key] === '' || params[key] === undefined) { delete params[key] diff --git a/src/views/asset-management/phone-asset-association/index.vue b/src/views/asset-management/phone-asset-association/index.vue index fabe1ef..a371c9d 100644 --- a/src/views/asset-management/phone-asset-association/index.vue +++ b/src/views/asset-management/phone-asset-association/index.vue @@ -297,8 +297,8 @@ { label: '资产标识', prop: 'asset_identifier', - type: 'input', - config: { clearable: true, placeholder: 'ICCID / 虚拟号 / IMEI / SN / 接入号(精确匹配)' } + type: 'business-select', + config: { entity: 'asset' } }, { label: '手机号', diff --git a/src/views/asset-management/record-management/asset-assign/index.vue b/src/views/asset-management/record-management/asset-assign/index.vue index 331b59a..3ed8340 100644 --- a/src/views/asset-management/record-management/asset-assign/index.vue +++ b/src/views/asset-management/record-management/asset-assign/index.vue @@ -131,11 +131,8 @@ { label: '资产标识符', prop: 'asset_identifier', - type: 'input', - config: { - clearable: true, - placeholder: 'ICCID或设备号' - } + type: 'business-select', + config: { entity: 'asset' } }, { label: '来源店铺', diff --git a/src/views/finance/refund/index.vue b/src/views/finance/refund/index.vue index 63d1d1a..6743f9b 100644 --- a/src/views/finance/refund/index.vue +++ b/src/views/finance/refund/index.vue @@ -93,7 +93,7 @@ { currentRefund.value = row // 预填充原有数据 - resubmitForm.requested_refund_amount = fenToYuan(row.requested_refund_amount) || undefined + resubmitForm.requested_refund_amount = + row.requested_refund_amount == null ? undefined : fenToYuan(row.requested_refund_amount) resubmitForm.method = '' resubmitForm.customer_account_name = '' resubmitForm.customer_account_number = '' diff --git a/src/views/order-management/agent-orders/index.vue b/src/views/order-management/agent-orders/index.vue new file mode 100644 index 0000000..36516e3 --- /dev/null +++ b/src/views/order-management/agent-orders/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/order-management/online-orders/index.vue b/src/views/order-management/online-orders/index.vue new file mode 100644 index 0000000..ebc3bd5 --- /dev/null +++ b/src/views/order-management/online-orders/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/order-management/order-list/detail.vue b/src/views/order-management/order-list/detail.vue index ac2b5e7..833e827 100644 --- a/src/views/order-management/order-list/detail.vue +++ b/src/views/order-management/order-list/detail.vue @@ -110,7 +110,8 @@ diff --git a/src/views/package-management/package-list/detail.vue b/src/views/package-management/package-list/detail.vue index 3e63ff2..acd402e 100644 --- a/src/views/package-management/package-list/detail.vue +++ b/src/views/package-management/package-list/detail.vue @@ -178,6 +178,22 @@ } }, { label: '套餐默认生效条件', prop: 'default_expiry_base_name' }, + { + label: '默认沉默期(天)', + formatter: (_: unknown, data: PackageResponse) => + data.package_type === 'formal' ? String(data.silence_period_days ?? '未配置') : '不适用' + }, + { + label: '支持付费延期', + formatter: (_: unknown, data: PackageResponse) => + data.package_type !== 'formal' + ? '不适用' + : data.silence_extension_enabled == null + ? '-' + : data.silence_extension_enabled + ? '是' + : '否' + }, { label: '分配覆盖生效条件', prop: 'expiry_base_override_name', diff --git a/src/views/package-management/package-list/index.vue b/src/views/package-management/package-list/index.vue index 2cea555..9c41ea2 100644 --- a/src/views/package-management/package-list/index.vue +++ b/src/views/package-management/package-list/index.vue @@ -60,11 +60,11 @@ - + @@ -197,6 +197,13 @@ + @@ -265,6 +272,7 @@ diff --git a/src/views/package-management/silence-batches/index.vue b/src/views/package-management/silence-batches/index.vue new file mode 100644 index 0000000..9ef8fd6 --- /dev/null +++ b/src/views/package-management/silence-batches/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/package-management/silence-orders/detail.vue b/src/views/package-management/silence-orders/detail.vue new file mode 100644 index 0000000..8b2343a --- /dev/null +++ b/src/views/package-management/silence-orders/detail.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/package-management/silence-orders/index.vue b/src/views/package-management/silence-orders/index.vue new file mode 100644 index 0000000..75f2690 --- /dev/null +++ b/src/views/package-management/silence-orders/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/package-management/silence-policies/index.vue b/src/views/package-management/silence-policies/index.vue new file mode 100644 index 0000000..5d3d15b --- /dev/null +++ b/src/views/package-management/silence-policies/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/package-management/silence-usages/index.vue b/src/views/package-management/silence-usages/index.vue new file mode 100644 index 0000000..7f07a2c --- /dev/null +++ b/src/views/package-management/silence-usages/index.vue @@ -0,0 +1,5 @@ + + diff --git a/src/views/polling-management/polling-pauses/index.vue b/src/views/polling-management/polling-pauses/index.vue index 60b3b98..ab7f5d9 100644 --- a/src/views/polling-management/polling-pauses/index.vue +++ b/src/views/polling-management/polling-pauses/index.vue @@ -158,12 +158,17 @@ ], config: { clearable: true } }, - { label: '资产标识', prop: 'identifier', type: 'input', config: { clearable: true } }, { - label: '关闭操作人 ID', + label: '资产', + prop: 'identifier', + type: 'business-select', + config: { entity: 'asset', allowed: platform.value && hasAuth(P.page) } + }, + { + label: '关闭操作人', prop: 'operator_id', - type: 'input', - config: { type: 'number', min: 0, clearable: true } + type: 'business-select', + config: { entity: 'account', includeSystem: true, allowed: platform.value && hasAuth(P.page) } }, { label: '关闭时间起止', diff --git a/src/views/polling-management/priority-queue/index.vue b/src/views/polling-management/priority-queue/index.vue index b5942c3..4cdbb47 100644 --- a/src/views/polling-management/priority-queue/index.vue +++ b/src/views/polling-management/priority-queue/index.vue @@ -220,10 +220,10 @@ // 搜索表单配置 const searchFormItems: SearchFormItem[] = [ { - label: '卡ID', + label: '网卡', prop: 'card_id', - type: 'input', - config: { clearable: true, placeholder: '请输入卡ID' } + type: 'business-select', + config: { entity: 'card' } }, { label: '任务类型', diff --git a/src/views/polling-management/stop-resume-dry-run/index.vue b/src/views/polling-management/stop-resume-dry-run/index.vue index 90d0476..3bf6b7e 100644 --- a/src/views/polling-management/stop-resume-dry-run/index.vue +++ b/src/views/polling-management/stop-resume-dry-run/index.vue @@ -101,7 +101,7 @@ assume_control_off: filters.assume_control_off.join(',') }) } - const { data, loading, error, load } = useSchedulerRequest( + const { data, loading, error, load, clear } = useSchedulerRequest( async () => { const result = gatewayData( await Service.dryRun({ ...query(), page: page.value, page_size: size.value }) @@ -184,6 +184,8 @@ void load() } function reset() { + // 重置不触发全量演练;同时使重置前的在途响应失效。 + clear() Object.assign(filters, { carrier_id: undefined, stop_reason: '', diff --git a/src/views/settings/package-silence-switch/index.vue b/src/views/settings/package-silence-switch/index.vue new file mode 100644 index 0000000..564556c --- /dev/null +++ b/src/views/settings/package-silence-switch/index.vue @@ -0,0 +1,87 @@ + + diff --git a/src/views/settings/system-configs/index.vue b/src/views/settings/system-configs/index.vue index 60f0ed6..6ef16cf 100644 --- a/src/views/settings/system-configs/index.vue +++ b/src/views/settings/system-configs/index.vue @@ -313,6 +313,7 @@ const getActions = (row: SystemConfigItem) => row.readonly || + row.config_key === 'package.silence.enabled' || ([PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(row.config_key as never) && !hasAuth(JULY_PERMISSIONS.systemConfig.payment)) ? [] @@ -344,6 +345,7 @@ } const showEditDialog = (row: SystemConfigItem) => { + if (row.config_key === 'package.silence.enabled') return currentConfig.value = row originalValue.value = row.value editValue.value = row.value @@ -358,7 +360,10 @@ ElMessage.error('未选择要编辑的系统配置') return } - if (currentConfig.value.readonly) { + if ( + currentConfig.value.readonly || + currentConfig.value.config_key === 'package.silence.enabled' + ) { ElMessage.warning('当前配置为只读配置,不能保存') return } diff --git a/src/views/settings/wecom/scenes/index.vue b/src/views/settings/wecom/scenes/index.vue index 4386d95..a3f4704 100644 --- a/src/views/settings/wecom/scenes/index.vue +++ b/src/views/settings/wecom/scenes/index.vue @@ -63,6 +63,7 @@ @change="handleBusinessTypeChange" > +