From 2fe7d0c50614acb488914dd9bb38477c5fb1920d Mon Sep 17 00:00:00 2001 From: Windows Date: Wed, 16 Sep 2026 18:17:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8A=95=E9=A1=BE):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E7=94=B3=E6=8A=A5=E6=8A=95=E9=A1=BE=E6=96=B9?= =?UTF-8?q?=E6=A1=88=20+=20=E6=8A=95=E9=A1=BE=E5=8F=97=E7=90=86=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90=E6=96=B9=E6=A1=88=E8=8D=89=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 → 投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。 后端 - 新增表 advisor_service_request(本轮新建,带 AUTO_INCREMENT)+ 迁移 20260916_advisor_service_request(幂等:先查表再建,兼容本库 alembic 指针滞后) - 新增 AdvisorServiceRequestService:create / list_mine / queue / review · 申报前置:风险测评必须存在且未失效(FM-03,12 个月),服务端判 · 队列复用 ProductRecommendationService._visible_customer_ids(本人 + 归属),待受理排前 · 受理即调用推荐逻辑生成草稿并把 content_id 回填;生成前置失败给出人话原因并保持待受理 · 「已发送客户」不落库,由关联方案 published_at 推导,避免两处状态各写各的 - 权限码 9071-9074(客户 write/read:self、投顾 read/review),已进种子与授权工具 前端 - 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见) - 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表 客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。 --- .../20260916_advisor_service_request.py | 68 ++++ .../controllers/advisor_service_requests.py | 85 +++++ app/model/advisor_service_request.py | 41 +++ .../advisor_service_request_service.py | 303 ++++++++++++++++++ app/static/portal/common/api-client.js | 16 + .../employee-advisor/dashboard/dashboard.js | 53 +-- .../employee-advisor/dashboard/index.html | 28 +- .../dashboard/service-request-module.js | 134 ++++++++ tools/check_portal_modules.py | 2 + tools/grant_advisor_role.py | 20 +- tools/seed_test_rbac.py | 17 + 11 files changed, 741 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/20260916_advisor_service_request.py create mode 100644 app/api/controllers/advisor_service_requests.py create mode 100644 app/model/advisor_service_request.py create mode 100644 app/service/advisor_service_request_service.py create mode 100644 app/static/portal/employee-advisor/dashboard/service-request-module.js diff --git a/alembic/versions/20260916_advisor_service_request.py b/alembic/versions/20260916_advisor_service_request.py new file mode 100644 index 0000000..d205983 --- /dev/null +++ b/alembic/versions/20260916_advisor_service_request.py @@ -0,0 +1,68 @@ +"""create advisor_service_request (客户申报 → 投顾受理) + +新增一张表:客户在「我的投顾方案」页主动申报,投顾在工作台受理/驳回。 +表内**新列全部可空或有业务默认**,不改动任何既有表,因此与基线完全兼容。 + +⚠️ 本表的 `id` 是 `AUTO_INCREMENT`:既有 `fin_*` 表缺自增(`20260914_baseline_auto_increment` +那次修复之后才补齐),新表直接按现代写法建,不再重复那个坑。 + +幂等:先查 `information_schema` 再建表 —— 本仓库的 alembic 版本指针与实际结构 +历史上就对不上(指针停在 `20260911_adv_profile_tags`),该表可能已被 DDL 手工建过; +无条件 `CREATE TABLE` 会在这种环境直接失败。 +""" + +from sqlalchemy import text + +from alembic import op + +revision = "20260916_advisor_service_request" +down_revision = "20260914_baseline_auto_increment" +branch_labels = None +depends_on = None + +CREATE_TABLE = """ +CREATE TABLE `advisor_service_request` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_no` VARCHAR(32) NOT NULL, + `customer_id` BIGINT UNSIGNED NOT NULL, + `amount_wan` DECIMAL(14,2) NOT NULL, + `horizon` VARCHAR(16) NOT NULL, + `risk_preference` VARCHAR(16) NOT NULL, + `note` VARCHAR(500) NULL, + `status` VARCHAR(24) NOT NULL, + `handled_by` BIGINT UNSIGNED NULL, + `handled_at` DATETIME NULL, + `advisor_note` VARCHAR(500) NULL, + `result_content_id` BIGINT UNSIGNED NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_advisor_service_request_no` (`request_no`), + KEY `idx_advisor_service_request_customer` (`customer_id`, `status`), + KEY `idx_advisor_service_request_advisor` (`status`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='客户主动申报的投顾服务申请单' +""" + + +def _exists() -> bool: + bind = op.get_bind() + return bool( + bind.execute( + text( + "SELECT COUNT(*) FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'advisor_service_request'" + ) + ).scalar() + ) + + +def upgrade() -> None: + if _exists(): + return + op.execute(CREATE_TABLE) + + +def downgrade() -> None: + # 表里是客户主动提交的申请,**不做自动删表**:需要回退请人工确认后执行。 + raise RuntimeError("advisor_service_request must not be dropped automatically") diff --git a/app/api/controllers/advisor_service_requests.py b/app/api/controllers/advisor_service_requests.py new file mode 100644 index 0000000..415c42d --- /dev/null +++ b/app/api/controllers/advisor_service_requests.py @@ -0,0 +1,85 @@ +"""客户申报投顾方案 + 投顾受理审批 的 HTTP 入口(`docs/05` 未登记的 AD 段新端点)。 + +| 段 | 端点 | 权限码 | 摘要 | +|---|---|---|---| +| 客户 | `POST /api/v1/users/me/advisor-requests` | `advisor-request:write:self` | 提交申报 | +| 客户 | `GET /api/v1/users/me/advisor-requests` | `advisor-request:read:self` | 我的申报(含状态) | +| 投顾 | `GET /api/v1/advisor/service-requests` | `advisor-request:read` | 名下客户的申报队列 | +| 投顾 | `POST /api/v1/advisor/service-requests/{request_no}/reviews` | `advisor-request:review` | 受理 / 驳回 | + +客户侧刻意**不挂投顾灰度闸门**(`enforce_advisor_rollout`)—— 那是投顾业务的灰度, +客户提交自己的服务申请不该被它拦下;投顾侧照旧挂上。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, Path + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.core.contracts import RequestContext +from app.core.errors import ValidationAgentError +from app.service.advisor_rollout_service import enforce_advisor_rollout +from app.service.advisor_service_request_service import ( + AdvisorServiceRequestCreate, + AdvisorServiceRequestService, +) + +client_router = APIRouter( + prefix="/api/v1/users/me", + tags=["advisor-service-requests"], + dependencies=[Depends(enforce_rate_limit)], +) +advisor_router = APIRouter( + prefix="/api/v1/advisor", + tags=["advisor-service-requests"], + dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)], +) + + +@client_router.post("/advisor-requests") +async def create_advisor_request( + payload: AdvisorServiceRequestCreate, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + """客户主动申报投顾方案(需已完成且未过期的风险测评)。""" + return await AdvisorServiceRequestService().create(payload, context, key) + + +@client_router.get("/advisor-requests") +async def my_advisor_requests( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """我的申报单(待受理 / 已受理 / 已发送 / 已驳回)。""" + return await AdvisorServiceRequestService().list_mine(context) + + +@advisor_router.get("/service-requests") +async def advisor_service_request_queue( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """名下客户提交的申报队列(待受理排前面)。""" + return await AdvisorServiceRequestService().queue(context) + + +@advisor_router.post("/service-requests/{request_no}/reviews") +async def review_advisor_service_request( + payload: dict[str, object], + request_no: str = Path(min_length=8, max_length=32), + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + """受理(**自动生成方案草稿**)或驳回客户的申报。 + + 受理后投顾仍需走既有的「审核通过 → 发送给客户」,方案才会到客户手里。 + """ + decision = payload.get("decision") + if decision not in {"accepted", "rejected"}: + raise ValidationAgentError("decision 必须为 accepted 或 rejected") + comment = payload.get("comment", "") + if not isinstance(comment, str): + raise ValidationAgentError("comment 必须是字符串") + return await AdvisorServiceRequestService().review( + request_no, str(decision), comment, context, key + ) diff --git a/app/model/advisor_service_request.py b/app/model/advisor_service_request.py new file mode 100644 index 0000000..6e10e1a --- /dev/null +++ b/app/model/advisor_service_request.py @@ -0,0 +1,41 @@ +"""客户主动申报投顾方案 → 投顾受理审批 的工单模型。""" + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, DateTime, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + +#: 状态机(`pending --受理--> accepted` / `pending --驳回--> rejected`)。 +#: 「已发送给客户」不单独存状态:它由 `result_content_id` 指向的方案是否已发布**推导**出来 +#: (见 `AdvisorServiceRequestService._view`),避免两处状态各写各的而对不上。 +PENDING = "pending" +ACCEPTED = "accepted" +REJECTED = "rejected" +DELIVERED = "delivered" + + +class AdvisorServiceRequest(Base): + """客户在「我的投顾方案」页发起的服务申请单。""" + + __tablename__ = "advisor_service_request" + + #: 本表是本轮新建的,**带上 AUTO_INCREMENT** —— 既有 `fin_*` 表缺自增 + #: 导致"不给 id 就插不进去"的坑,新表不再重复踩。 + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + request_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + amount_wan: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + horizon: Mapped[str] = mapped_column(String(16), nullable=False) + risk_preference: Mapped[str] = mapped_column(String(16), nullable=False) + note: Mapped[str | None] = mapped_column(String(500)) + status: Mapped[str] = mapped_column(String(24), nullable=False) + handled_by: Mapped[int | None] = mapped_column(BigInteger) + handled_at: Mapped[datetime | None] = mapped_column(DateTime) + advisor_note: Mapped[str | None] = mapped_column(String(500)) + #: 受理时自动生成的方案(`client_facing_content.id`);驳回时为 None。 + result_content_id: Mapped[int | None] = mapped_column(BigInteger) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/service/advisor_service_request_service.py b/app/service/advisor_service_request_service.py new file mode 100644 index 0000000..813f1b3 --- /dev/null +++ b/app/service/advisor_service_request_service.py @@ -0,0 +1,303 @@ +"""客户主动申报投顾方案 → 投顾受理审批。 + +## 流程 + + 客户「我的投顾方案」页提交申报(金额/期限/风险偏好/备注) + → 落 `advisor_service_request`(status=pending) + → 投顾工作台「客户申报」看到待办 + → 投顾受理:**自动跑一次推荐**,生成一份 pending_review 的方案草稿, + 并把草稿 id 记进 `result_content_id`(status=accepted) + → 投顾随后走既有的「审核通过 → 发送给客户」 + → 客户在「我的投顾方案」看到方案(status 呈现为 delivered) + +## 两条前置/边界 + +1. **申报前置**:必须已完成风险测评且未失效(FM-03:12 个月)。服务端判, + 不依赖前端闸门 —— 前端只是体验层。 +2. **归属**:投顾只能处理**名下客户**的申报,复用 + `ProductRecommendationService._visible_customer_ids`(本人 + `sys_customer_assignment`), + 与已发布方案/历史记录同一把尺子。 + +## 状态不重复存 + +「已发送给客户」不单独存状态,而是由 `result_content_id` 指向的方案是否已发布 +**推导**(见 `_view`)—— 两处各存一份必然对不上。 +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select + +from app.core.contracts import RequestContext +from app.core.errors import ( + ForbiddenAgentError, + GenericResourceNotFoundError, + InvalidStateError, + ValidationAgentError, +) +from app.core.product_recommendation_contracts import ProductRecommendationQuery +from app.infrastructure.db import SessionFactory +from app.model.advisor_service_request import ( + ACCEPTED, + DELIVERED, + PENDING, + REJECTED, + AdvisorServiceRequest, +) +from app.model.investment_goal import ClientFacingContent +from app.service.api_transaction_service import ApiTransactionService +from app.service.authorization_service import AuthorizationService +from app.service.product_recommendation_service import ProductRecommendationService +from app.service.suitability_service import SuitabilityService + +logger = logging.getLogger(__name__) + +#: 与投顾工作台左栏表单的选项一致;服务端仍要校验一次(前端只是体验层)。 +HORIZONS: tuple[str, ...] = ("<1年", "1-3年", "3-5年", ">5年") +RISK_PREFERENCES: tuple[str, ...] = ("稳健", "平衡", "进取") + +#: FM-03:风险测评 12 个月失效。 +ASSESSMENT_VALID_DAYS = 365 + + +def _naive_utc(value: datetime) -> datetime: + """归一成 **UTC naive** 再比较。 + + 适配器返回的时间可能带 tz(`valid_until`),而 `now` 是 naive —— + 直接比较会 `TypeError: can't compare offset-naive and offset-aware datetimes`。 + """ + if value.tzinfo is None: + return value + return value.astimezone(UTC).replace(tzinfo=None) + +#: 受理时推荐逻辑的前置失败 → 给投顾看得懂的话(`generate` 的 early return 状态码)。 +GENERATE_FAILURES: dict[str, str] = { + "profile_required": "客户尚未完成风险测评,无法生成方案", + "investment_goal_required": "客户暂无已确认投资目标,请先录入并确认目标", + "recommendation_input_invalid": "客户投资目标数据不完整,无法生成方案", +} + + +class AdvisorServiceRequestCreate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + amount_wan: float = Field(gt=0, le=1_000_000, description="拟投资金额(万元)") + horizon: str = Field(description="投资期限") + risk_preference: str = Field(description="风险偏好") + note: str | None = Field(default=None, max_length=500, description="补充说明") + + +class AdvisorServiceRequestService: + # ---- 客户侧 ---- + + async def create( + self, payload: AdvisorServiceRequestCreate, context: RequestContext, key: str | None + ) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:write:self") + if payload.horizon not in HORIZONS: + raise ValidationAgentError(f"投资期限只能是 {list(HORIZONS)} 之一") + if payload.risk_preference not in RISK_PREFERENCES: + raise ValidationAgentError(f"风险偏好只能是 {list(RISK_PREFERENCES)} 之一") + customer_id = int(context.user_id) + await self._require_valid_assessment(customer_id) + + async def operation(session: Any) -> dict[str, object]: + now = datetime.now(UTC).replace(tzinfo=None) + # 同一客户同一秒可能连点两次:后缀加短随机码,避免撞 `request_no` 唯一键。 + request_no = f"ASR-{customer_id}-{now:%y%m%d%H%M%S}-{uuid.uuid4().hex[:4].upper()}" + row = AdvisorServiceRequest( + request_no=request_no, + customer_id=customer_id, + amount_wan=Decimal(str(payload.amount_wan)), + horizon=payload.horizon, + risk_preference=payload.risk_preference, + note=payload.note, + status=PENDING, + created_at=now, + updated_at=now, + ) + session.add(row) + await session.flush() + return { + "data": {"request_no": request_no, "status": PENDING}, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor-request:create:{customer_id}", + key, + payload.model_dump(mode="json"), + operation, + ) + + async def list_mine(self, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:read:self") + rows = await self._fetch(customer_ids=[int(context.user_id)]) + rows.sort(key=lambda row: row.created_at, reverse=True) + return {"data": await self._views(rows), "meta": {"trace_id": context.trace_id}} + + # ---- 投顾侧 ---- + + async def queue(self, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:read") + customer_ids = list(ProductRecommendationService._visible_customer_ids(context)) + rows = await self._fetch(customer_ids=customer_ids) + # 待受理排前面,其余按时间倒序 —— 投顾打开面板先看到"要动手的"。 + rows.sort(key=lambda row: (row.status != PENDING, -row.created_at.timestamp())) + return {"data": await self._views(rows), "meta": {"trace_id": context.trace_id}} + + async def review( + self, + request_no: str, + decision: str, + comment: str, + context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:review") + if decision not in {"accepted", "rejected"}: + raise ValidationAgentError("decision 必须为 accepted 或 rejected") + + async with SessionFactory() as session: + existing = await session.scalar( + select(AdvisorServiceRequest).where( + AdvisorServiceRequest.request_no == request_no + ) + ) + if existing is None: + raise GenericResourceNotFoundError("申报单不存在") + visible = set(ProductRecommendationService._visible_customer_ids(context)) + if existing.customer_id not in visible: + raise ForbiddenAgentError("无权处理该客户的申报") + if existing.status != PENDING: + raise InvalidStateError("该申报单已被处理") + + content_id: int | None = None + if decision == "accepted": + content_id = await self._generate_draft_plan(request_no, existing.customer_id, context) + + async def operation(session: Any) -> dict[str, object]: + row = await session.get(AdvisorServiceRequest, existing.id, with_for_update=True) + if row is None or row.status != PENDING: + raise InvalidStateError("该申报单已被处理") + now = datetime.now(UTC).replace(tzinfo=None) + row.status = ACCEPTED if decision == "accepted" else REJECTED + row.handled_by = int(context.user_id) + row.handled_at = now + row.advisor_note = comment or None + row.result_content_id = content_id + row.updated_at = now + await session.flush() + return { + "data": { + "request_no": row.request_no, + "status": row.status, + "result_content_id": str(content_id) if content_id else None, + }, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor-request:{request_no}:review", + key, + {"decision": decision, "comment": comment}, + operation, + ) + + # ---- 内部 ---- + + async def _require_valid_assessment(self, customer_id: int) -> None: + """申报前置:必须已完成风险测评且未失效(FM-03:12 个月)。""" + profile = await SuitabilityService().authority_for_customer(customer_id) + if profile.customer_risk_level is None: + raise InvalidStateError("请先完成风险测评,再申报投顾方案") + now = datetime.now(UTC).replace(tzinfo=None) + if profile.valid_until is not None: + if _naive_utc(profile.valid_until) < now: + raise InvalidStateError("风险测评已过期,请重新测评后再申报") + return + # 没写有效期时按 FM-03 的 12 个月口径兜底 —— 与前端熔断规则同一口径。 + if ( + profile.assessed_at is not None + and now - _naive_utc(profile.assessed_at) > timedelta(days=ASSESSMENT_VALID_DAYS) + ): + raise InvalidStateError("风险测评已超过 12 个月,请重新测评后再申报") + + async def _generate_draft_plan( + self, request_no: str, customer_id: int, context: RequestContext + ) -> int: + """受理时自动出草稿:沿用既有推荐逻辑(硬约束 + 适当性 + 排序 + LLM 依据)。""" + result = await ProductRecommendationService( + enforce_profile_governance=True + ).generate( + ProductRecommendationQuery(customer_id=customer_id, limit=3), + context, + f"advisor-request-{request_no}-generate", + ) + status = result.get("status") + if isinstance(status, str) and status in GENERATE_FAILURES: + raise InvalidStateError(GENERATE_FAILURES[status]) + payload = result.get("data") + content_id = str((payload or {}).get("content_id") or "") if isinstance(payload, dict) else "" + if not content_id.isdigit(): + raise InvalidStateError("方案生成未返回方案编号,请稍后重试") + return int(content_id) + + @staticmethod + async def _fetch(customer_ids: list[int]) -> list[AdvisorServiceRequest]: + if not customer_ids: + return [] + async with SessionFactory() as session: + return list( + await session.scalars( + select(AdvisorServiceRequest) + .where(AdvisorServiceRequest.customer_id.in_(customer_ids)) + .order_by(AdvisorServiceRequest.created_at.desc()) + .limit(50) + ) + ) + + @staticmethod + async def _views(rows: list[AdvisorServiceRequest]) -> list[dict[str, object]]: + """补上「方案是否已发布」——`delivered` 是**推导**出来的,不单独存状态。""" + content_ids = [row.result_content_id for row in rows if row.result_content_id] + published: dict[int, bool] = {} + if content_ids: + async with SessionFactory() as session: + found = ( + await session.execute( + select(ClientFacingContent.id, ClientFacingContent.published_at).where( + ClientFacingContent.id.in_(content_ids) + ) + ) + ).all() + published = {int(cid): published_at is not None for cid, published_at in found} + views: list[dict[str, object]] = [] + for row in rows: + delivered = bool( + row.result_content_id and published.get(int(row.result_content_id)) + ) + status = DELIVERED if delivered else row.status + views.append({ + "request_no": row.request_no, + "customer_id": str(row.customer_id), + "amount_wan": float(row.amount_wan), + "horizon": row.horizon, + "risk_preference": row.risk_preference, + "note": row.note, + "status": status, + "advisor_note": row.advisor_note, + "handled_at": row.handled_at.isoformat() if row.handled_at else None, + "result_content_id": str(row.result_content_id) if row.result_content_id else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + }) + return views diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 60e6bac..218a403 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -93,11 +93,27 @@ const ENDPOINTS = Object.freeze({ T008: { method: 'GET', path: '/api/v1/users/me/transactions/{transactionNo}' }, T009: { method: 'GET', path: '/api/v1/users/me/cash-ledger' }, ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' }, + // 历史方案留档(本人 + 名下客户、**全部状态**,含待审/已驳回)。投顾工作台 + // 「历史方案记录」面板用;与 ADVISOR_PUBLISHED 的区别只在状态口径(后者只给已发布)。 + ADVISOR_HISTORY: { method: 'GET', path: '/api/v1/advisor/recommendations/history' }, // 投顾自助审核/发布自己生成的推荐方案(2026-09-14 起): // 服务层不再额外要求 admin 角色,但仍要求 `product-recommendation:review` / // `product-recommendation:publish` 两个权限码(仅 advisor 与 admin 持有)。 ADVISOR_REVIEW_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/reviews', idempotent: true }, ADVISOR_PUBLISH_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/publications', idempotent: true }, + // 删除推荐方案(历史记录里的「删除」)。仅推荐方案可删 —— 投资方案书被 + // `advisor_investment_goal.goal_book_content_id`(NO ACTION 外键)引用,删不掉。 + // 权限码 `product-recommendation:delete`(advisor 与 admin 持有)。 + ADVISOR_DELETE_RECOMMENDATION: { method: 'DELETE', path: '/api/v1/advisor/recommendations/{contentId}', idempotent: true }, + // 客户侧:**本人**已发布的投顾交付物(推荐方案 + 投资方案书)。 + // 是投顾「发送给客户」的落点,见 `customer/advisor-plans/`。 + MY_ADVISOR_CONTENTS: { method: 'GET', path: '/api/v1/users/me/advisor-contents' }, + // 客户**主动申报**投顾方案(本人提交 / 查看自己的申报状态)。 + MY_ADVISOR_REQUESTS: { method: 'GET', path: '/api/v1/users/me/advisor-requests' }, + ADVISOR_REQUEST_CREATE: { method: 'POST', path: '/api/v1/users/me/advisor-requests', idempotent: true }, + // 投顾侧:名下客户的申报队列 + 受理/驳回(**受理会自动生成一份待审方案草稿**)。 + ADVISOR_SERVICE_REQUESTS: { method: 'GET', path: '/api/v1/advisor/service-requests' }, + ADVISOR_SERVICE_REQUEST_REVIEW: { method: 'POST', path: '/api/v1/advisor/service-requests/{requestNo}/reviews', idempotent: true }, // ⚠️ 保留:前端契约测试(`tests/unit/api/test_portal_frontend.py`)把"页面会用到的端点" // 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用 // (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。 diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js index 50885df..e7d5f26 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.js +++ b/app/static/portal/employee-advisor/dashboard/dashboard.js @@ -1,14 +1,14 @@ -// 投顾工作台。本文件只做**组合**,能力拆在五个模块里: +// 投顾工作台。本文件只做**组合**,能力拆在六个模块里: // // · advisor-config.js 文案 / 操作 / 熔断规则 / 演示数据的唯一来源 // · advisor-engine.js 本地演示引擎(纯函数:后端无接口的两个操作 + 离线回退) // · customer-module.js 我的客户列表与选中客户(整个工作台的上下文) // · actions-module.js 七项操作:表单、请求、熔断闸门、结果渲染 -// · published-module.js 已发布交付物(只读列表) +// · history-module.js 历史方案记录(只读列表:全部状态的方案与方案书) // · assistant-module.js 统一对话入口(前端意图路由) // // 构图:**左栏(投顾身份 / 我的客户+参数+动作按钮+自然语言输入 / 执行日志)+ -// 主区(推荐流水线 / 结果 / 已发布交付物)**。结果与对话回答共用同一个容器。 +// 主区(推荐流水线 / 结果 / 历史方案记录)**。结果与对话回答共用同一个容器。 // // 每个模块各自 import 它需要的公共件(apiClient / formatters / state-view), // 本层不再重复 import —— 同一个东西两处 import,改一处漏一处是这类拆分的典型退化。 @@ -28,16 +28,17 @@ // —— 版本号不一致会让同一个文件被两个 URL 引入,模块被实例化成两份。 // (import 说明符是静态的,没法用变量拼,只能逐处写。) import { clearAuthSession, getAccessToken, getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913'; -import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3'; import { escapeHtml } from '/static/portal/common/formatters.js?v=20260913'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderLoading } from '/static/portal/common/state-view.js?v=20260913'; import { METRIC_HINTS, PIPELINE_STEPS } from './advisor-config.js?v=20260914-advisor9'; import { assessmentFuseHits } from './advisor-engine.js?v=20260914-advisor9'; -import { createActionsModule } from './actions-module.js?v=20260914-advisor9'; +import { createActionsModule } from './actions-module.js?v=20260916-plan3'; import { createAssistantModule } from './assistant-module.js?v=20260914-advisor9'; import { createCustomerModule } from './customer-module.js?v=20260914-advisor9'; -import { createPublishedModule } from './published-module.js?v=20260914-advisor9'; +import { createHistoryModule } from './history-module.js?v=20260916-history3'; +import { createServiceRequestModule } from './service-request-module.js?v=20260916-plan3'; const DEMO_MODE = new URLSearchParams(window.location.search).get('demo') === '1'; @@ -106,7 +107,7 @@ function start(context) { // // 形态与「平台治理工作台」「风险工作台」一致:label / value / meta 三元组。 // 数据分两批到:客户相关的一开始就能算(本地数据),已发布方案数要等接口回来 - // (见下面 `published` 的 `onClick` 回调)。所以先渲染一次、拿到数再渲染一次, + // (见下方 `ADVISOR_PUBLISHED` 取数)。所以先渲染一次、拿到数再渲染一次, // 单张卡在非登录态显示「--」而不是假 0 —— 0 和「不知道」是两回事。 const metricBox = document.querySelector('[data-advisor-metrics]'); let publishedCount = null; @@ -163,11 +164,6 @@ function start(context) { online, }); - const published = createPublishedModule({ - list: document.querySelector('[data-recommendations]'), - onClick: (count) => { publishedCount = count; renderMetrics(); }, - }); - const assistant = createAssistantModule({ log, output: document.querySelector('[data-action-output]'), @@ -178,24 +174,41 @@ function start(context) { online, }); - document.querySelector('[data-refresh]').addEventListener('click', () => published.load()); + // 历史方案记录(只读 + 详情弹窗):与其它模块无耦合,独立取数即可。 + const history = createHistoryModule({ + list: document.querySelector('[data-history]'), + dialog: document.querySelector('[data-history-dialog]'), + }); + + // 客户申报队列:受理即自动生成方案草稿(后端跑既有推荐逻辑)。 + const serviceRequests = createServiceRequestModule({ + list: document.querySelector('[data-service-requests]'), + }); + actions.bind(); assistant.bind(); + document.querySelector('[data-history-refresh]').addEventListener('click', () => history.load()); + document.querySelector('[data-service-request-refresh]') + ?.addEventListener('click', () => serviceRequests.load()); document.querySelector('[data-boot-hint]')?.remove(); customers.render(); renderMetrics(); if (online()) { - // 已发布交付物只在登录后才有意义;顺手用它的成败判断后端是否在线 —— - // 比单独打一次 /health 更省一次请求,失败时也仍然有降级文案。 - published.load().then(() => { - apiClient.get('HEALTH') - .then(() => { backendOnline = true; setStatus('后端在线 · FastAPI', null); renderMetrics(); }) - .catch(() => { backendOnline = false; setStatus('后端未连接 · 本地引擎', 'medium'); renderMetrics(); }); - }); + // 历史方案记录 + 顶部「已发布方案数」指标卡都要真实后端;后端在线与否由 /health 判断。 + history.load(); + serviceRequests.load(); + apiClient.get('ADVISOR_PUBLISHED') + .then((response) => { publishedCount = Array.isArray(response.data) ? response.data.length : 0; renderMetrics(); }) + .catch(() => { publishedCount = 0; renderMetrics(); }); + apiClient.get('HEALTH') + .then(() => { backendOnline = true; setStatus('后端在线 · FastAPI', null); renderMetrics(); }) + .catch(() => { backendOnline = false; setStatus('后端未连接 · 本地引擎', 'medium'); renderMetrics(); }); } else { backendOnline = false; + history.showPlaceholder(); + serviceRequests.showPlaceholder(); renderMetrics(); actions.execute('recommend'); } diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html index 0bac14d..9e6c9bd 100644 --- a/app/static/portal/employee-advisor/dashboard/index.html +++ b/app/static/portal/employee-advisor/dashboard/index.html @@ -6,7 +6,8 @@ 投顾工作台 · 南方财富 - + +
@@ -116,14 +117,31 @@
-

已发布交付物

- +

客户申报

+
-
+
+
+ +
+
+

历史方案记录

+ +
+
- + + +
+

方案详情

+ +
+
+
+ + diff --git a/app/static/portal/employee-advisor/dashboard/service-request-module.js b/app/static/portal/employee-advisor/dashboard/service-request-module.js new file mode 100644 index 0000000..485b791 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/service-request-module.js @@ -0,0 +1,134 @@ +// 客户申报队列(投顾工作台)。 +// +// 客户在「我的投顾方案」页主动申报 → 这里出现待办 → 投顾「受理」时后端**自动跑一次推荐**, +// 生成一份 pending_review 的方案草稿;投顾随后走既有的「审核通过 → 发送给客户」。 +// +// 状态口径(后端 `AdvisorServiceRequestService`):`pending` 待受理 / `accepted` 已受理 +// / `rejected` 已驳回;**`delivered`(已发送)是推导出来的** —— 由关联方案是否已发布决定, +// 不在本模块里猜。 + +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3'; +import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913'; +import { showToast } from '/static/portal/common/notifications.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913'; + +//: 状态 → 文案 / 标签色。 +const STATUS_LABELS = Object.freeze({ + pending: '待受理', + accepted: '已受理 · 方案待审核', + delivered: '已发送客户', + rejected: '已驳回', +}); +const STATUS_TONES = Object.freeze({ + pending: 'medium', + accepted: 'active', + delivered: 'low', + rejected: 'failed', +}); + +function statusLabel(status) { + return STATUS_LABELS[status] || String(status || '--'); +} + +function cardHtml(item) { + const tone = STATUS_TONES[item.status] || 'active'; + const pending = item.status === 'pending'; + return `
` + + `

申报单 ${escapeHtml(item.request_no)}` + + ` ${escapeHtml(statusLabel(item.status))}

` + + `

客户 ${escapeHtml(item.customer_id)}` + + ` · ${escapeHtml(String(item.amount_wan))} 万元` + + ` · 期限 ${escapeHtml(item.horizon || '--')}` + + ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}` + + ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}

` + + (item.note ? `

客户备注:${escapeHtml(item.note)}

` : '') + + (item.advisor_note + ? `

处理意见:${escapeHtml(item.advisor_note)}

` + : '') + + (item.result_content_id + ? `

关联方案:编号 ${escapeHtml(item.result_content_id)}` + + `${item.status === 'delivered' ? '(已发送客户)' : '(草稿待审核)'}

` + : '') + + (pending + ? '
' + + `' + + `
' + : '') + + '
'; +} + +export function createServiceRequestModule({ list }) { + let rows = []; + let busy = false; + + function rowOf(requestNo) { + return rows.find((item) => item.request_no === requestNo) || null; + } + + async function review(requestNo, decision) { + if (busy) return; + let comment = ''; + if (decision === 'rejected') { + const input = window.prompt('驳回理由(可选,会展示给客户):', ''); + if (input === null) return; // 取消 + comment = input.slice(0, 200); + } else if (!window.confirm(`确认受理申报单 ${requestNo}?将自动为该客户生成一份方案草稿。`)) { + return; + } + busy = true; + list.setAttribute('aria-busy', 'true'); + try { + const response = await apiClient.post( + 'ADVISOR_SERVICE_REQUEST_REVIEW', + { decision, comment }, + { pathParams: { requestNo } }, + ); + const contentId = response?.data?.result_content_id; + showToast(decision === 'accepted' + ? `已受理,方案草稿已生成(编号 ${contentId || '--'}),请在结果区审核并发送给客户` + : `申报单 ${requestNo} 已驳回`); + await load(); + // 让历史记录等模块同步刷新(方案草稿刚被创建)。 + window.dispatchEvent(new CustomEvent('advisor:published-refresh')); + } catch (error) { + apiClient.reportError(error); + showToast(error.message || '受理未完成', 'error'); + } finally { + busy = false; + list.removeAttribute('aria-busy'); + } + } + + list.addEventListener('click', (event) => { + const button = event.target.closest('[data-request-action]'); + if (!button) return; + void review(button.dataset.requestNo, button.dataset.requestAction); + }); + + async function load() { + renderLoading(list, 2); + try { + const response = await apiClient.get('ADVISOR_SERVICE_REQUESTS'); + rows = Array.isArray(response.data) ? response.data : []; + if (!rows.length) { + renderEmpty(list, '暂无客户申报', '客户在「我的投顾方案」页提交申报后,待办会出现在这里。'); + return; + } + list.innerHTML = rows.map(cardHtml).join(''); + } catch (error) { + rows = []; + apiClient.reportError(error); + renderError(list, error, load); + } + } + + function showPlaceholder() { + renderEmpty(list, '客户申报需登录后查看', '当前为本地演示模式,未连接后端,因此没有申报可处理。'); + } + + return Object.freeze({ load, showPlaceholder, rowOf }); +} diff --git a/tools/check_portal_modules.py b/tools/check_portal_modules.py index f4fcf4a..f83978a 100644 --- a/tools/check_portal_modules.py +++ b/tools/check_portal_modules.py @@ -40,6 +40,8 @@ DASHBOARD_DIR = PORTAL / "employee-advisor" / "dashboard" MODULES = [ "dashboard.js", "actions-module.js", + "history-module.js", + "service-request-module.js", "published-module.js", "assistant-module.js", "customer-module.js", diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py index 219db95..1b6db45 100644 --- a/tools/grant_advisor_role.py +++ b/tools/grant_advisor_role.py @@ -27,6 +27,7 @@ |---|---|---| | 投顾工作流(10) | `asset-allocation:generate:self`、`investment-goal:read:self` / `:review` / `:publish`、`portfolio-analysis:read:self`、`product-comparison:read:self`、`product-recommendation:read:self` / `:generate:self` / `:review` / `:publish` | `advisor` + `admin` —— **定义在种子的 9020-9034** | | 治理类(3,本脚本建) | `product-governance:read` / `:review` / `:sync` | **只给 `admin`** | +| 方案操作(3,本脚本声明,种子里是 9031/9032/9070) | `product-recommendation:review` / `:publish` / `:delete` | `advisor` + `admin` —— 用于**修复没跑过种子的环境**(否则工作台四个按钮整体 403) | | 治理类(种子已含) | `asset-allocation:backtest`、`profile-governance:read` / `:review` | **只给 `admin`** | `review` / `publish` 也给投顾,与项目既有决策一致 —— 此前已裁定**不做双人复核** @@ -63,12 +64,25 @@ ADVISOR_ROLE_NAME = "投资顾问" #: 1. 种子 `seed_test_rbac.py` 已占 9001-9034(含投顾线的 9020-9034); #: 2. 库里曾有一批 9020-9035 是本脚本用**旧号段**建的,与种子的 9020-9034 #: **id→code 映射不同** —— 整体挪到 9041 之后,与两侧都不冲突。 -#: 只定义种子里**缺**的这 3 个治理类权限码;其余 13 个由种子提供。 +#: 本脚本声明 6 行:3 个治理类(种子里没有,9041-9043)+ 3 个方案操作 +#: (种子里有,9031/9032/9070 —— 声明出来是为了在**没跑过种子的环境**里补齐)。 +#: `check_rbac_seed_consistency.py` 要求这里每行的 id+code 与种子完全一致。 #: (id, permission_code, resource, action, data_scope) ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( (9041, "product-governance:read", "product-governance", "read", "all"), (9042, "product-governance:review", "product-governance", "review", "all"), (9043, "product-governance:sync", "product-governance", "sync", "all"), + # 推荐方案的审核/发布/删除。**定义在种子里**(9031/9032/9070),这里再声明一遍 + # 是为了让本脚本能**修复缺失的环境**:有些库(如本机演示库)从没跑过 + # `seed_test_rbac.py`,这三行就不存在 —— 结果是投顾工作台「审核通过 / 驳回 / + # 发送给客户 / 删除」四个按钮**整体 403**,而报错只显示"权限不足"。 + # 本脚本按 code 判重,缺哪行补哪行(只增不删)。 + (9031, "product-recommendation:review", "product-recommendation", "review", "all"), + (9032, "product-recommendation:publish", "product-recommendation", "publish", "all"), + (9070, "product-recommendation:delete", "product-recommendation", "delete", "all"), + # 客户申报受理(队列 + 受理/驳回)。定义在种子的 9073/9074。 + (9073, "advisor-request:read", "advisor-request", "read", "all"), + (9074, "advisor-request:review", "advisor-request", "review", "all"), ) #: 投顾拿哪些。2026-09-12 补齐:此前只给了 10 项工作流权限,结果投顾**用不了** @@ -102,6 +116,10 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = ( "portfolio-analysis:read:customer", "product-recommendation:review", "product-recommendation:publish", + "product-recommendation:delete", + # 客户申报受理:看名下客户的申报队列 + 受理/驳回(受理会自动出方案草稿)。 + "advisor-request:read", + "advisor-request:review", # 平台通用:投顾同样要跑 Agent、查行情、检索知识、看所服务客户的画像 "agent:run", "suitability:read", diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index 53f17a9..450d6c3 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -180,6 +180,21 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( # 状态机与审计见 `app/service/customer_service_handover_action_service.py`。 # data_scope 取 `all`:工单队列本身就是全平台视图,不按归属客户切。 (9069, "handover:write", "handover", "write", "all"), + # ---- 9070:推荐方案**删除** ---- + # 投顾工作台「历史方案记录」的四个按钮里,审核/发布复用 9031/9032,删除此前 + # **没有权限码**(`ProductRecommendationService.delete` 是本轮新增)。 + # 不给它单独的 `:self` 变体:删除按 content_id 寻址,归属由服务层的 + # `_visible_customer_ids`(本人 + 名下客户)兜底,与 published/history 同一把尺子。 + (9070, "product-recommendation:delete", "product-recommendation", "delete", "all"), + # ---- 9071-9074:客户**主动申报**投顾方案 + 投顾受理审批 ---- + # 客户在自己主页提交申报(金额/期限/风险偏好),投顾在工作台受理后自动出草稿。 + # 客户侧用 `:self`(只能看/提自己的);投顾侧 `read`/`review` 为 `all`, + # 但**真正的范围限制在服务层**:队列按 `sys_customer_assignment` 归属过滤 + # (`AdvisorServiceRequestService.queue` 复用 `_visible_customer_ids`)。 + (9071, "advisor-request:write:self", "advisor-request", "write", "self"), + (9072, "advisor-request:read:self", "advisor-request", "read", "self"), + (9073, "advisor-request:read", "advisor-request", "read", "all"), + (9074, "advisor-request:review", "advisor-request", "review", "all"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。 @@ -190,6 +205,8 @@ CUSTOMER_PERMISSIONS = ( 9044, # ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量) 9060, 9061, 9062, 9063, 9064, 9065, + # 客户主动申报投顾方案(提交 + 查看自己的申报) + 9071, 9072, ) # 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。 RISK_PERMISSIONS = (