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 @@