From 7a49a1c2c8aa80cfee814147c73d04d619be61ca Mon Sep 17 00:00:00 2001 From: yujiangjiang12 Date: Mon, 14 Sep 2026 01:07:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A2=81=E8=81=AA=E7=9A=84=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=B0=83=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 486 ++++++++++++++++ app/api/schemas/offsite_fund.py | 2 +- .../offsite_document_recognition_adapter.py | 104 +++- app/service/offsite_fund_rules.py | 45 +- app/service/offsite_fund_service.py | 108 +++- app/service/promotion_material_service.py | 20 +- app/static/portal/common/api-client.js | 72 ++- app/static/portal/common/formatters.js | 10 +- app/static/portal/common/layout/app-shell.js | 24 +- .../dashboard/dashboard.css | 8 + .../employee-operations/dashboard/index.html | 2 +- .../employee-operations/nl2sql/index.html | 35 ++ .../employee-operations/nl2sql/nl2sql.js | 136 +++++ .../employee-operations/offsite/index.html | 58 ++ .../employee-operations/offsite/offsite.js | 529 ++++++++++++++++++ .../operator-workspace.css | 83 +++ .../employee-operations/promotion/index.html | 45 ++ .../promotion/promotion.js | 241 ++++++++ tests/integration/test_offsite_fund_api.py | 5 +- .../test_promotion_material_api.py | 3 +- ...st_offsite_document_recognition_adapter.py | 24 +- tests/unit/service/test_offsite_fund_rules.py | 7 + tools/grant_promotion_operator.py | 154 +++++ 23 files changed, 2150 insertions(+), 51 deletions(-) create mode 100644 README.md create mode 100644 app/static/portal/employee-operations/nl2sql/index.html create mode 100644 app/static/portal/employee-operations/nl2sql/nl2sql.js create mode 100644 app/static/portal/employee-operations/offsite/index.html create mode 100644 app/static/portal/employee-operations/offsite/offsite.js create mode 100644 app/static/portal/employee-operations/operator-workspace.css create mode 100644 app/static/portal/employee-operations/promotion/index.html create mode 100644 app/static/portal/employee-operations/promotion/promotion.js create mode 100644 tools/grant_promotion_operator.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..7597fdf --- /dev/null +++ b/README.md @@ -0,0 +1,486 @@ +# 南方基金智能业务平台 + +本项目是一个基于 FastAPI 和 MVC+S 架构的金融业务 Agent 平台,当前包含以下三项核心业务: + +1. 场外基金申购/赎回运营流程 +2. 产品推介材料与宣传海报生成 +3. 金融自然语言转 SQL(NL2SQL)查询 + +项目中的场外基金运营、产品推介材料与场内基金模拟交易相互隔离。场外业务使用独立的 +`offsite_fund_*` 数据表和接口,不写入场内交易表。 + +## 一、技术栈 + +- Python `3.13` +- FastAPI、Uvicorn +- SQLAlchemy 2.x、Alembic +- MySQL、Redis +- Pydantic v2 +- JWT + RBAC +- `python-pptx`、OpenPyXL、Pillow、Matplotlib +- 可选:阿里云 DocMind/OCR、DeepSeek、SMTP、Milvus、Neo4j + +## 二、项目结构 + +```text +group_fqcd_jr/ +├── app/ +│ ├── api/controllers/ # HTTP 路由和请求参数校验 +│ ├── api/schemas/ # 请求/响应模型 +│ ├── core/ # 业务契约、配置和公共规则 +│ ├── model/ # SQLAlchemy 数据模型 +│ ├── service/ # 业务编排、规则和数据访问 +│ ├── infrastructure/ # 数据库、缓存和外部基础设施适配 +│ └── worker/ # 异步 Worker +├── data/ # 测试与演示数据集 +├── docs/ # 架构、接口、数据库和业务说明 +├── tests/ # 单元、契约、集成和环境测试 +├── tools/ # 迁移、种子、检查和测试工具 +├── alembic/ # 数据库迁移 +├── hq.py # 南方基金行情数据适配模块 +├── nl2sql_yc.py # 金融 NL2SQL 兼容入口和查询逻辑 +├── .env.example # 环境变量模板 +└── README.md +``` + +## 三、环境准备 + +项目要求 Python `>=3.13,<3.14`。建议使用独立虚拟环境: + +```powershell +conda create -n jr_py313 python=3.13 +conda activate jr_py313 +pip install -r requirements.txt +``` + +复制配置文件: + +```powershell +Copy-Item .env.example .env +``` + +然后根据本机环境修改 `.env`,至少配置: + +```text +MYSQL_DSN +JWT_PRIVATE_KEY_PATH +JWT_PUBLIC_KEY_PATH +``` + +`.env`、JWT 私钥、模型密钥和真实外部服务凭据禁止提交到 Git。 + +## 四、启动服务 + +### 4.1 数据库迁移 + +请确保 MySQL 已启动,并且 `.env` 中的数据库连接可用: + +```powershell +alembic upgrade head +python tools/audit_schema.py +``` + +### 4.2 启动 HTTP 服务 + +```powershell +python -m uvicorn app.main:app --host 127.0.0.1 --port 8099 +``` + +服务地址: + +```text +http://127.0.0.1:8099 +``` + +OpenAPI 文档: + +```text +http://127.0.0.1:8099/docs +``` + +### 4.3 启动 Worker + +需要异步处理 Agent 运行或场外邮件时,另开终端: + +```powershell +python -m app.worker +``` + +Worker 是否启用场外收信,取决于: + +```text +OFFSITE_MAIL_WORKER_ENABLED +OFFSITE_IMAP_ENABLED +OFFSITE_WORKER_USER_ID +``` + +## 五、功能一:场外基金申购/赎回 + +### 5.1 功能说明 + +场外流程面向运营人员,主要处理: + +```text +邮件接收 +→ 附件保存 +→ OCR/文档识别 +→ 结构化字段提取 +→ 申购/赎回规则计算 +→ NL2SQL 查询资金和持仓数据 +→ 运营人员确认 +→ 创建通知 +→ 发送或 dry-run +→ 审计和统计 +``` + +支持的单据类型: + +- `subscription`:申购 +- `redemption`:赎回 +- `summary`:汇总材料 +- `other`:其他材料 + +系统保留原始邮件、附件、识别结果、查询记录、规则结果、人工修正记录和审计记录。 +人工修正不会覆盖 Agent 原始识别值。 + +### 5.2 主要接口 + +基础路径: + +```text +/api/v1/offsite-fund +``` + +| 方法 | 路径 | 用途 | +|---|---|---| +| `GET` | `/mails` | 分页查询场外邮件 | +| `GET` | `/mails/{mail_id}` | 查看邮件详情 | +| `POST` | `/mails/{mail_id}/deletions` | 软删除邮件 | +| `GET` | `/mails/{mail_id}/recognition-fields` | 查看 OCR 识别字段 | +| `PUT` | `/mails/{mail_id}/recognition-fields` | 保存 OCR 字段人工修正 | +| `GET` | `/documents/{task_id}/nl2sql-fields` | 查看 NL2SQL 返回字段 | +| `PUT` | `/documents/{task_id}/nl2sql-fields` | 保存 NL2SQL 字段人工修正 | +| `GET` | `/documents/{task_id}/rule-results` | 查看规则判定结果 | +| `POST` | `/documents/{task_id}/rule-results/recalculations` | 使用已有结果重新判定 | +| `GET` | `/mailbox-status` | 查看收件游标状态 | +| `POST` | `/mailbox-status/recoveries` | 恢复被阻塞的收件游标 | +| `GET` | `/attachments/{attachment_id}/file` | 预览或下载原始附件 | +| `POST` | `/documents/{task_id}/confirmations` | 运营人员确认单据 | +| `POST` | `/documents/{task_id}/recognition-retries` | 重试识别异常单据 | +| `POST` | `/documents/{task_id}/notifications` | 创建运营通知 | +| `POST` | `/notifications/{notification_id}/send` | 发送通知 | +| `POST` | `/settlement-statistics/recalculate` | 重算结算统计 | + +触发场外单据 NL2SQL 核对的接口为: + +```text +POST /api/tasks/{task_id}/trigger-agent-nl2sql +``` + +### 5.3 权限与安全 + +- 读取权限:`offsite:read` +- 写入权限:`offsite:write` +- 确认权限:`offsite:confirm` +- NL2SQL 权限:`offsite:nl2sql` +- 通知权限:`offsite:notify` +- 请求中的 `operator_id` 必须与 JWT 身份一致 +- 所有写操作都进行权限校验、操作人校验、审计和状态流转校验 +- 通知发送支持幂等,SMTP 默认关闭并使用 dry-run + +## 六、功能二:产品推介材料与宣传海报生成 + +### 6.1 功能说明 + +该模块用于新产品的结构化资料整理和宣传材料生成。当前生成链路为: + +```text +创建任务 +→ 填写产品、经理、团队、策略、费用、业绩和风险资料 +→ 上传经理照片、业绩数据、来源证据或模板 +→ 合规检查 +→ 生成 PPTX、宣传海报和可选 PDF +→ 审核 +→ 发送给指定投顾 +``` + +支持的输入附件: + +- `manager_photo`:基金经理照片 +- `performance_data`:CSV/XLSX 业绩数据 +- `source_evidence`:来源证据 +- `template_file`:材料模板 + +支持的输出格式: + +- `pptx` +- `poster` +- `pdf`,需要配置 LibreOffice/`soffice` 等转换器 + +模块会执行风险披露、费用结构、业绩数据、输入完整性和生成文本合规检查。 +合规阻断时返回具体 findings 和补齐建议,不继续生成材料。 + +### 6.2 主要接口 + +基础路径: + +```text +/api/v1/fund-promotion-materials +``` + +| 方法 | 路径 | 用途 | +|---|---|---| +| `POST` | `/` | 创建推介材料任务 | +| `PUT` | `/{task_no}/inputs` | 保存结构化输入资料 | +| `POST` | `/{task_no}/attachments` | 上传照片、业绩文件或证据 | +| `POST` | `/{task_no}/generations` | 生成推介材料 | +| `GET` | `/{task_no}/compliance-checks` | 查看合规检查结果 | +| `GET` | `/{task_no}` | 查询任务和已审核材料 | +| `POST` | `/{task_no}/reviews` | 审核、驳回或要求修改 | +| `POST` | `/{task_no}/deliveries` | 发送给投顾 | + +创建任务和写入操作必须提供请求头: + +```text +Idempotency-Key: <唯一请求键> +``` + +### 6.3 权限与状态 + +主要权限: + +- `promotion:write` +- `promotion:read` +- `promotion:review` +- `promotion:deliver` + +材料任务状态包括: + +```text +draft +→ input_ready +→ generating +→ generated +→ pending_review +→ approved +→ sent +``` + +异常状态包括: + +```text +compliance_failed +rejected +failed +``` + +生成的文件默认保存到: + +```text +data/promotion_materials/ +``` + +## 七、功能三:金融 NL2SQL + +### 7.1 功能说明 + +NL2SQL 将自然语言问题转换为经过权限、业务域、表白名单和只读校验的 SQL。 +业务 Agent 不直接连接数据库,而是通过公共只读工具调用: + +```text +业务 Agent +→ BaseAgent.call_tool +→ ToolExecutor +→ query_financial_data +→ 只读金融数据表 +``` + +工具名称: + +```text +query_financial_data +``` + +权限码: + +```text +financial:nl2sql:read +``` + +### 7.2 支持范围 + +当前纳入查询范围的主要表: + +```text +sys_customer_assignment +fin_customer_profile +fin_risk_assessment +fin_product +fin_fee_rule +fin_market_price +fin_nav_history +fin_holding +fin_transaction +fin_sim_order +fin_sim_account +fin_cash_ledger +client_facing_content +``` + +支持: + +- 基金净值、行情和历史收益查询 +- 费率和费用查询 +- 客户风险测评和画像查询 +- 持仓、交易、订单、账户和资金流水查询 +- 历史范围查询 +- 最多跨三个业务域查询 +- 结果行数限制,默认 50,最大 200 +- SQL dry-run + +不支持或会被拒绝: + +- 非白名单表 +- 非 `SELECT` 查询 +- 未通过客户数据范围校验的查询 +- 当前快照表的历史时点查询 +- 模糊且未确认查询范围、指标口径或时间条件的问题 + +### 7.3 调用示例 + +```python +result = await self.call_tool( + "query_financial_data", + { + "question": "查询基金代码 000001 最近 30 天的净值", + "dry_run": False, + "limit": 50, + }, + intent="financial_query", + context=context, +) +``` + +模糊问题会返回: + +```json +{ + "status": "need_confirmation", + "message": "请确认查询范围、指标口径和时间条件。" +} +``` + +工具结果包含查询计划、SQL、参数、权限检查和执行摘要,并进入统一审计和运行持久化链路。 + +## 八、测试数据集 + +测试数据位于: + +```text +data/ +``` + +当前数据集概况: + +| 类型 | 数量 | 用途 | +|---|---:|---| +| `.eml` | 13 | 场外邮件及附件识别 | +| `.csv` / `.xlsx` | 8 | 推介材料业绩数据 | +| `.png` / `.jpg` | 35 | 经理照片、宣传图和测试图片 | +| `.pptx` | 10 | 推介材料结构检查 | +| `.pdf` | 9 | PDF 文件有效性检查 | + +测试数据只用于本地测试和演示,不应直接作为生产业务数据导入。 + +## 九、测试与代码检查 + +### 9.1 运行完整测试 + +普通单元和契约测试: + +```powershell +python -m pytest tests/unit tests/contract -q -p no:cacheprovider +``` + +使用独立 MySQL 测试库并执行测试数据隔离: + +```powershell +python -m tools.run_tests_on_test_db tests -p no:cacheprovider +``` + +### 9.2 运行三项功能定向测试 + +```powershell +python -m pytest ` + tests/integration/test_offsite_fund_api.py ` + tests/integration/test_offsite_nl2sql_fields.py ` + tests/integration/test_offsite_notification_send.py ` + tests/integration/test_promotion_material_api.py ` + tests/unit/service/test_suitability_service.py ` + tests/contract/test_financial_nl2sql_tool_contract.py ` + -q -p no:cacheprovider +``` + +### 9.3 代码质量检查 + +```powershell +python -m ruff check app tests tools alembic +python -m mypy app +git diff --check +``` + +### 9.4 测试基线 + +最近一次项目回归记录: + +```text +关键三项功能定向测试:85 passed +全量自动化测试:559 passed, 1 skipped +``` + +Redis、真实 IMAP/OCR/DeepSeek/SMTP、Neo4j、Milvus 和浏览器 E2E 是否能执行, +取决于本机服务和凭据配置。外部服务关闭或不可用时,测试使用本地数据集、Mock 或 dry-run。 + +## 十、配置开关 + +场外邮件和外部识别: + +```text +OFFSITE_IMAP_ENABLED=false +OFFSITE_MAIL_WORKER_ENABLED=false +OFFSITE_OCR_ENABLED=false +OFFSITE_DEEPSEEK_ENABLED=false +OFFSITE_SMTP_ENABLED=false +OFFSITE_SMTP_DRY_RUN=true +``` + +产品推介材料: + +```text +PROMOTION_MATERIAL_STORAGE_DIR=data/promotion_materials +PROMOTION_PDF_ENABLED=false +PROMOTION_PDF_CONVERTER_PATH= +``` + +启用真实服务前,请先完成网络、凭据、权限、数据脱敏和回滚方案确认。 + +## 十一、安全与开发约束 + +- 所有业务请求都应经过统一 JWT、RBAC、客户范围和审计链路。 +- 不在业务 Agent 中自行读取密钥、直接连接数据库或绕过 `ToolExecutor`。 +- 场外业务不能写入场内交易表。 +- 数据库结构只能通过 Alembic 迁移修改。 +- 不删除、重命名或复用已有表和字段;历史结构不足时新增字段、新表或兼容读写。 +- 所有写接口应设计幂等键、状态机和重复提交保护。 +- 真实外部服务默认关闭,测试优先使用本地数据、Mock 和 dry-run。 + +## 十二、相关文档 + +- `docs/MVC/MVC架构.md`:项目架构和目录职责 +- `docs/00-新数据库基线设计.md`:数据库业务基线 +- `docs/05-接口文档.md`:接口权威说明 +- `docs/14-Agent组员统一接入说明书.md`:Agent 接入规范 +- `docs/15-金融NL2SQL工具接入说明.md`:NL2SQL 工具规范 +- `product_promotion_to_do_list.md`:推介材料模块开发记录 +- `测试报告/2026-09-12.md`:三项功能数据集测试报告 + diff --git a/app/api/schemas/offsite_fund.py b/app/api/schemas/offsite_fund.py index 58dd682..2a027da 100644 --- a/app/api/schemas/offsite_fund.py +++ b/app/api/schemas/offsite_fund.py @@ -15,7 +15,7 @@ class OffsiteConfirmRequest(BaseModel): class OffsiteRecalculateRequest(BaseModel): model_config = ConfigDict(extra="forbid") - fund_code: str = Field(min_length=1, max_length=32) + fund_code: str | None = Field(default=None, max_length=32) application_date: str = Field(min_length=8, max_length=32) diff --git a/app/service/offsite_document_recognition_adapter.py b/app/service/offsite_document_recognition_adapter.py index 4b3b9e2..7c157e5 100644 --- a/app/service/offsite_document_recognition_adapter.py +++ b/app/service/offsite_document_recognition_adapter.py @@ -56,6 +56,7 @@ EXTRACTED_FIELD_NAMES: tuple[str, ...] = ( "申请日期", "代销机构", "申购金额", + "币种", "金额单位", "赎回份额", "最新净值", @@ -486,7 +487,9 @@ class OffsiteDocumentRecognitionAdapter: try: body = await self._call_deepseek(source, ocr) document_type = _document_type(body.get("document_type")) - fields = _recognized_fields(body) + fields = normalize_recognition_fields( + _recognized_fields(body), ocr.ocr_text + ) confidence = _confidence_map(_mapping_dict(body, "field_confidence")) page_evidence = _mapping_dict(body, "page_evidence") or ocr.page_evidence missing_fields = tuple( @@ -539,6 +542,10 @@ class OffsiteDocumentRecognitionAdapter: "page_evidence六个顶层字段。" "extracted_fields必须是对象,字段名只能使用下面列出的中文字段;" "无法从原文确认的字段填null,不得猜测或编造。" + "申购金额只能填写数字本身,不得包含人民币、RMB、CNY、元、万元、" + "货币符号或千分位逗号;币种单独填写到币种字段,金额单位单独填写到金额单位字段。" + "例如原文“人民币5,000元”必须输出申购金额“5000”、币种“人民币”、" + "金额单位“元”。" "文档中的产品代码、产品编号、基金产品代码均映射为基金代码;" "基金代码不要求固定六位,必须按原文保留。" "document_type只能是summary、subscription、redemption、other。" @@ -547,7 +554,7 @@ class OffsiteDocumentRecognitionAdapter: '{"基金代码":null,"基金名称":null,"账户标识":null,' '"投资者名称":null,"客户标识":null,"申请编号":null,' '"申请日期":null,"代销机构":null,"申购金额":null,' - '"金额单位":null,"赎回份额":null,"最新净值":null,' + '"币种":null,"金额单位":null,"赎回份额":null,"最新净值":null,' '"基金最新总份额":null,"申请前持有份额":null,' '"当前最新可用份额":null},' '"field_confidence":{},"missing_fields":[],' @@ -718,7 +725,7 @@ def _extract_simple_fields(text: str) -> dict[str, object]: ) if value: fields[name] = value - return fields + return normalize_recognition_fields(fields, text) def _find_label_value(text: str, label: str) -> str | None: @@ -729,6 +736,97 @@ def _find_label_value(text: str, label: str) -> str | None: return matched.group(1).strip() +_CURRENCY_MARKERS: tuple[tuple[str, str], ...] = ( + ("人民币", "人民币"), + ("RMB", "人民币"), + ("CNY", "人民币"), + ("¥", "人民币"), + ("¥", "人民币"), + ("美元", "美元"), + ("USD", "美元"), + ("$", "美元"), + ("港币", "港币"), + ("HKD", "港币"), + ("欧元", "欧元"), + ("EUR", "欧元"), + ("日元", "日元"), + ("JPY", "日元"), +) + + +def normalize_recognition_fields( + fields: Mapping[str, object], source_text: str = "" +) -> dict[str, object]: + """统一识别字段格式,保证金额与币种、单位分开保存。""" + normalized = dict(fields) + raw_amount = normalized.get("申购金额") + if raw_amount is None or str(raw_amount).strip() == "": + return normalized + + amount_text = str(raw_amount).strip().replace(",", "").replace(",", "") + currency = _normalize_currency(normalized.get("币种")) + if not currency: + currency = _currency_from_text(amount_text) + if not currency: + currency = _currency_from_amount_context(source_text) + if currency: + normalized["币种"] = currency + + unit = str(normalized.get("金额单位") or "").strip() + if unit: + if unit.endswith("万元"): + unit = "万元" + elif unit.endswith("元"): + unit = "元" + normalized["金额单位"] = unit + + for marker, _ in _CURRENCY_MARKERS: + amount_text = re.sub( + rf"^{re.escape(marker)}\s*", + "", + amount_text, + flags=re.IGNORECASE, + ) + amount_text = re.sub( + rf"\s*{re.escape(marker)}$", + "", + amount_text, + flags=re.IGNORECASE, + ) + if unit: + amount_text = re.sub(rf"\s*{re.escape(unit)}$", "", amount_text) + matched = re.search(r"-?(?:\d+(?:\.\d*)?|\.\d+)", amount_text) + if matched: + normalized["申购金额"] = matched.group(0) + return normalized + + +def _normalize_currency(value: object) -> str | None: + text = str(value or "").strip() + if not text: + return None + return _currency_from_text(text) or text + + +def _currency_from_text(text: str) -> str | None: + lowered = text.lower() + for marker, currency in _CURRENCY_MARKERS: + if marker.lower() in lowered: + return currency + return None + + +def _currency_from_amount_context(source_text: str) -> str | None: + if not source_text: + return None + matched = re.search( + r"申购金额\s*[::]?\s*([^\s,,;;]+)", + source_text, + flags=re.IGNORECASE, + ) + return _currency_from_text(matched.group(1)) if matched else None + + def _extract_docx_text(payload: bytes) -> str: try: with zipfile.ZipFile(BytesIO(payload)) as archive: diff --git a/app/service/offsite_fund_rules.py b/app/service/offsite_fund_rules.py index c571d91..69f6f99 100644 --- a/app/service/offsite_fund_rules.py +++ b/app/service/offsite_fund_rules.py @@ -13,6 +13,14 @@ TWENTY_PERCENT = Decimal("0.20") ONE_YUAN = Decimal("1") +def display_decimal(value: Decimal) -> str: + """把计算结果格式化成适合页面展示的十进制文本。""" + text = format(value, "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + @dataclass(frozen=True) class RuleDecision: rule_code: str @@ -88,7 +96,13 @@ class OffsiteFundRuleEngine: document_value={"申购金额元": str(amount_yuan)}, database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares), "申请前持有份额": str(before)}, - calculation={"本次申购份额": str(current_shares), "申购后持有比例": str(ratio)}, + calculation={ + "本次申购份额": str(current_shares), + "申购后持有比例": str(ratio), + "实际值": f"{display_decimal(ratio * HUNDRED)}%", + "规则值": "≤ 20%", + "比较": f"{display_decimal(ratio * HUNDRED)}% ≤ 20%", + }, )) limit = total_fund_shares * TEN_PERCENT decisions.append(RuleDecision( @@ -97,7 +111,13 @@ class OffsiteFundRuleEngine: result="异常" if current_shares > limit else "正常", document_value={"申购金额元": str(amount_yuan)}, database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares)}, - calculation={"本次申购份额": str(current_shares), "份额上限": str(limit)}, + calculation={ + "本次申购份额": str(current_shares), + "份额上限": str(limit), + "实际值": str(current_shares), + "规则值": str(limit), + "比较": f"{current_shares} ≤ {limit}", + }, )) return decisions @@ -118,7 +138,12 @@ class OffsiteFundRuleEngine: result="异常" if value > TWENTY_PERCENT else "正常", document_value={"赎回份额": str(redemption_shares)}, database_value={"产品最新总份额": str(total_fund_shares)}, - calculation={"赎回比例": str(value)}, + calculation={ + "赎回比例": str(value), + "实际值": f"{display_decimal(value * HUNDRED)}%", + "规则值": "≤ 20%", + "比较": f"{display_decimal(value * HUNDRED)}% ≤ 20%", + }, ) if redemption_shares is None or available_quantity is None: available = self._unknown("redemption_available_quantity", "账户可用份额") @@ -129,7 +154,12 @@ class OffsiteFundRuleEngine: result="异常" if redemption_shares > available_quantity else "正常", document_value={"赎回份额": str(redemption_shares)}, database_value={"当前最新可用份额": str(available_quantity)}, - calculation={"是否超出可用份额": redemption_shares > available_quantity}, + calculation={ + "是否超出可用份额": redemption_shares > available_quantity, + "实际值": str(redemption_shares), + "规则值": str(available_quantity), + "比较": f"{redemption_shares} ≤ {available_quantity}", + }, ) return [ratio, available] @@ -144,7 +174,12 @@ class OffsiteFundRuleEngine: result=result, document_value={"申购金额元": str(amount_yuan) if amount_yuan is not None else None}, database_value={}, - calculation={"判断口径": "标准化申购金额 <= 1 元为异常"}, + calculation={ + "判断口径": "标准化申购金额 <= 1 元为异常", + "实际值": str(amount_yuan) if amount_yuan is not None else None, + "规则值": "> 1 元", + "比较": f"{amount_yuan} > 1 元" if amount_yuan is not None else None, + }, ) @staticmethod diff --git a/app/service/offsite_fund_service.py b/app/service/offsite_fund_service.py index d46e4cb..7304e7d 100644 --- a/app/service/offsite_fund_service.py +++ b/app/service/offsite_fund_service.py @@ -46,6 +46,7 @@ from app.service.offsite_document_recognition_adapter import ( OffsiteDocumentRecognitionAdapter, RecognitionSourceFile, StructuredRecognitionResult, + normalize_recognition_fields, ) from app.service.offsite_fund_rules import ( OffsiteFundRuleEngine, @@ -364,7 +365,9 @@ class OffsiteFundService: correction_fields = ( dict(correction.corrected_fields) if correction is not None else {} ) - return self._merge_corrections(attachment.extracted_fields, correction_fields) + return normalize_recognition_fields( + self._merge_corrections(attachment.extracted_fields, correction_fields) + ) @staticmethod def _merge_corrections( @@ -443,8 +446,8 @@ class OffsiteFundService: "status": attachment.status, "ocr_text": attachment.ocr_text, "extracted_fields": attachment.extracted_fields, - "effective_fields": cls._merge_corrections( - attachment.extracted_fields, correction_fields + "effective_fields": normalize_recognition_fields( + cls._merge_corrections(attachment.extracted_fields, correction_fields) ), "corrections": cls._correction_payload(correction), "has_correction": correction is not None, @@ -799,8 +802,9 @@ class OffsiteFundService: "message": f"附件 {attachment_id} 不属于该邮件", "data": {}, } + normalized_fields = normalize_recognition_fields(fields) cleaned = self._cleaned_corrections( - fields, EXTRACTED_FIELD_NAMES, attachment.extracted_fields + normalized_fields, EXTRACTED_FIELD_NAMES, attachment.extracted_fields ) self.session.add( OffsiteFieldCorrection( @@ -815,8 +819,8 @@ class OffsiteFundService: created_at=now, ) ) - effective = self._merge_corrections( - attachment.extracted_fields, cleaned + effective = normalize_recognition_fields( + self._merge_corrections(attachment.extracted_fields, cleaned) ) for document in documents_by_attachment.get(attachment_id, []): self._apply_recognition_fields(document, effective) @@ -1758,6 +1762,7 @@ class OffsiteFundService: def _apply_recognition_fields( document: OffsiteFundDocument, fields: Mapping[str, object] ) -> None: + fields = normalize_recognition_fields(fields) raw_date = fields.get("申请日期") document.fund_code = OffsiteFundService._text(fields.get("基金代码")) document.fund_name = OffsiteFundService._text(fields.get("基金名称")) @@ -1836,6 +1841,7 @@ class OffsiteFundService: self, task_id: str, decision: OperationDecision, operator_id: str, context: RequestContext, ) -> dict[str, object]: + task_id = task_id.strip() operator_error = self._operator_error(operator_id, context) if operator_error is not None: return operator_error @@ -1870,7 +1876,7 @@ class OffsiteFundService: return {"code": 0, "message": "ok", "data": {"task_id": task_id, "decision": decision}} async def recalculate_statistics( - self, fund_code: str, application_date: str, context: RequestContext + self, fund_code: str | None, application_date: str, context: RequestContext ) -> dict[str, object]: denied = self._permission_error(context, ("offsite:read", "offsite:write")) if denied is not None: @@ -1878,11 +1884,17 @@ class OffsiteFundService: target_date = parse_application_date(application_date) if target_date is None: return {"code": 422, "message": "申请日期格式不正确", "data": {}} - documents = (await self.session.execute(select(OffsiteFundDocument).where( - OffsiteFundDocument.fund_code == fund_code, + requested_fund_code = fund_code.strip() if fund_code else None + filters = [ OffsiteFundDocument.application_date == target_date, OffsiteFundDocument.operator_decision == "确认正常", - ))).scalars().all() + OffsiteFundDocument.fund_code.is_not(None), + ] + if requested_fund_code: + filters.append(OffsiteFundDocument.fund_code == requested_fund_code) + documents = (await self.session.execute( + select(OffsiteFundDocument).where(*filters) + )).scalars().all() task_ids = [document.task_id for document in documents] successful_normal_returns: set[str] = set() if task_ids: @@ -1899,6 +1911,42 @@ class OffsiteFundService: document for document in documents if document.task_id in successful_normal_returns ] + grouped: dict[str, list[OffsiteFundDocument]] = defaultdict(list) + for document in documents: + if document.fund_code: + grouped.setdefault(document.fund_code, []) + for row in rows: + if row.fund_code: + grouped[row.fund_code].append(row) + if requested_fund_code and requested_fund_code not in grouped: + grouped[requested_fund_code] = [] + statistics = [ + await self._build_settlement_statistic( + code, target_date, grouped[code], context + ) + for code in sorted(grouped) + ] + if requested_fund_code: + data = {**statistics[0], "items": statistics} + return {"code": 0, "message": "ok", "data": data} + return { + "code": 0, + "message": "ok", + "data": { + "application_date": target_date.isoformat(), + "fund_code": None, + "fund_count": len(statistics), + "items": statistics, + }, + } + + async def _build_settlement_statistic( + self, + fund_code: str, + target_date: date, + rows: Sequence[OffsiteFundDocument], + context: RequestContext, + ) -> dict[str, object]: subscription_total = sum( ((row.subscription_amount_yuan or Decimal("0")) for row in rows), Decimal("0"), @@ -1907,7 +1955,6 @@ class OffsiteFundService: ((row.redemption_shares or Decimal("0")) for row in rows), Decimal("0"), ) - agency_breakdown = self._agency_breakdown(rows) latest_nav = await self._query_latest_nav(fund_code, target_date, context) redemption_amount_yuan = ( redemption_total * latest_nav if latest_nav is not None else None @@ -1917,22 +1964,29 @@ class OffsiteFundService: if redemption_amount_yuan is not None else (subscription_total if redemption_total == 0 else None) ) - return {"code": 0, "message": "ok", "data": { - "fund_code": fund_code, "application_date": target_date.isoformat(), + return { + "fund_code": fund_code, + "application_date": target_date.isoformat(), "fund_name": next((row.fund_name for row in rows if row.fund_name), None), "subscription_amount_yuan": str(subscription_total), - "subscription_count": sum(1 for row in rows if row.document_type == "subscription"), + "subscription_count": sum( + 1 for row in rows if row.document_type == "subscription" + ), "redemption_shares": str(redemption_total), - "redemption_count": sum(1 for row in rows if row.document_type == "redemption"), + "redemption_count": sum( + 1 for row in rows if row.document_type == "redemption" + ), "latest_nav": str(latest_nav) if latest_nav is not None else None, "redemption_amount_yuan": ( - str(redemption_amount_yuan) if redemption_amount_yuan is not None else None + str(redemption_amount_yuan) + if redemption_amount_yuan is not None + else None ), "net_flow_amount_yuan": ( str(net_flow_amount_yuan) if net_flow_amount_yuan is not None else None ), - "agency_breakdown": agency_breakdown, - }} + "agency_breakdown": self._agency_breakdown(rows), + } async def trigger_agent_nl2sql( self, task_id: str, operator_id: str, manual_confirmed: bool, @@ -2020,6 +2074,9 @@ class OffsiteFundService: ) records.append({ "rule_code": rule_code, + "rule_name": dict( + self._offsite_rule_titles(document.document_type) + ).get(rule_code, rule_code), "status": query_status, "row_count": 1 if row is not None else 0, }) @@ -2050,6 +2107,7 @@ class OffsiteFundService: self, task_id: str, notification_type: str, operator_id: str, context: RequestContext, ) -> dict[str, object]: + task_id = task_id.strip() operator_error = self._operator_error(operator_id, context) if operator_error is not None: return operator_error @@ -2547,6 +2605,20 @@ class OffsiteFundService: error_message=None, created_at=now, updated_at=now, )) + @staticmethod + def _offsite_rule_titles( + document_type: str, + ) -> tuple[tuple[str, str], ...]: + if document_type == "redemption": + return ( + ("redemption_large_ratio", "赎回巨额比例"), + ("redemption_available_quantity", "账户可用份额"), + ) + return ( + ("subscription_holding_ratio", "申购后单一投资者持有比例"), + ("subscription_single_share_limit", "申购单笔份额上限"), + ) + @staticmethod def _nl2sql_questions(document: OffsiteFundDocument) -> list[tuple[str, str]]: if not document.fund_code: diff --git a/app/service/promotion_material_service.py b/app/service/promotion_material_service.py index 07eb380..bb9d22d 100644 --- a/app/service/promotion_material_service.py +++ b/app/service/promotion_material_service.py @@ -299,10 +299,22 @@ class PromotionMaterialService: context: RequestContext, ) -> dict[str, Any]: task = await self._task(session, task_no) + normalized_media_type = ( + media_type + or mimetypes.guess_type(filename)[0] + or "application/octet-stream" + ) + # 部分 Windows 浏览器或代理会把图片上传为通用二进制类型; + # 扩展名和 PIL 内容校验仍会继续约束文件,不能只信任请求头。 + if ( + attachment_type == "manager_photo" + and not normalized_media_type.startswith("image/") + ): + normalized_media_type = mimetypes.guess_type(filename)[0] or normalized_media_type self._validate_attachment( attachment_type, filename, - media_type, + normalized_media_type, len(payload), max_photo_size=self.max_photo_size, max_performance_size=self.max_performance_size, @@ -338,11 +350,7 @@ class PromotionMaterialService: task_no=task_no, attachment_type=attachment_type, filename=Path(filename).name, - media_type=( - media_type - or mimetypes.guess_type(filename)[0] - or "application/octet-stream" - ), + media_type=normalized_media_type, file_hash=digest, size_bytes=len(payload), file_path=str(destination), diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index d93549d..87fa792 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -55,7 +55,33 @@ const ENDPOINTS = Object.freeze({ ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' }, ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis' }, OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' }, + OFFSITE_MAIL: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}' }, + OFFSITE_MAIL_DELETE: { method: 'POST', path: '/api/v1/offsite-fund/mails/{mailId}/deletions', idempotent: true }, + OFFSITE_RECOGNITION: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields' }, + OFFSITE_RECOGNITION_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields', idempotent: true }, + OFFSITE_NL2SQL_FIELDS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields' }, + OFFSITE_NL2SQL_FIELDS_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields', idempotent: true }, + OFFSITE_RULE_RESULTS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results' }, + OFFSITE_RULE_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results/recalculations', idempotent: true }, OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' }, + OFFSITE_MAILBOX_RECOVER: { method: 'POST', path: '/api/v1/offsite-fund/mailbox-status/recoveries', idempotent: true }, + OFFSITE_ATTACHMENT_FILE: { method: 'GET', path: '/api/v1/offsite-fund/attachments/{attachmentId}/file' }, + OFFSITE_CONFIRM: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/confirmations', idempotent: true }, + OFFSITE_RECOGNITION_RETRY: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/recognition-retries', idempotent: true }, + OFFSITE_NOTIFICATION_CREATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/notifications', idempotent: true }, + OFFSITE_NOTIFICATION_SEND: { method: 'POST', path: '/api/v1/offsite-fund/notifications/{notificationId}/send', idempotent: true }, + OFFSITE_SETTLEMENT_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/settlement-statistics/recalculate', idempotent: true }, + OFFSITE_TRIGGER_NL2SQL: { method: 'POST', path: '/api/tasks/{taskId}/trigger-agent-nl2sql', idempotent: true }, + PROMOTION_CREATE: { method: 'POST', path: '/api/v1/fund-promotion-materials', idempotent: true }, + PROMOTION_TASK: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}' }, + PROMOTION_INPUTS: { method: 'PUT', path: '/api/v1/fund-promotion-materials/{taskNo}/inputs', idempotent: true }, + PROMOTION_ATTACHMENT: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/attachments', formData: true, idempotent: true }, + PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true }, + PROMOTION_CHECKS: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}/compliance-checks' }, + PROMOTION_REVIEW: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/reviews', idempotent: true }, + PROMOTION_DELIVERY: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/deliveries', idempotent: true }, + AGENT_RUN_CREATE: { method: 'POST', path: '/api/v1/agent-runs', idempotent: true }, + AGENT_RUN: { method: 'GET', path: '/api/v1/agent-runs/{runId}' }, }); export class ApiError extends Error { @@ -67,6 +93,7 @@ export class ApiError extends Error { this.retryable = Boolean(options.retryable); this.fieldErrors = options.fieldErrors || []; this.traceId = options.traceId || ''; + this.payload = options.payload || null; } } @@ -114,13 +141,15 @@ async function request(endpointId, options = {}) { body: options.body === undefined || endpoint.method === 'GET' ? undefined : (endpoint.formData ? options.body : JSON.stringify(options.body)), + cache: 'no-store', signal: controller.signal, }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && endpoint.auth !== false) clearAuthSession(); const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId; document.documentElement.dataset.traceId = responseTraceId; - if (!response.ok || payload.error) { + const hasBusinessError = Object.prototype.hasOwnProperty.call(payload, 'code') && payload.code !== 0; + if (!response.ok || payload.error || hasBusinessError) { const detail = payload.error || {}; const validationDetail = Array.isArray(payload.detail) ? payload.detail @@ -129,14 +158,19 @@ async function request(endpointId, options = {}) { .join(';') : (typeof payload.detail === 'string' ? payload.detail : ''); const message = detail.message + || (hasBusinessError ? payload.message : '') || validationDetail || (response.status ? `请求失败(HTTP ${response.status})` : '请求未完成'); + const businessStatus = hasBusinessError && Number(payload.code) >= 400 + ? Number(payload.code) + : response.status; const error = new ApiError(message, { - code: detail.code, - status: response.status, + code: detail.code || (hasBusinessError ? `BUSINESS_${payload.code}` : undefined), + status: businessStatus, retryable: detail.retryable, fieldErrors: detail.field_errors, traceId: responseTraceId, + payload, }); if (response.status === 429 && attempt === 0) await wait(5000); else if (shouldRetry(error, attempt)) await wait(2000); @@ -213,10 +247,42 @@ async function stream(endpointId, body, options = {}) { if (buffer.trim()) dispatch(buffer); } +async function requestFile(endpointId, options = {}) { + const endpoint = ENDPOINTS[endpointId]; + if (!endpoint) throw new ApiError(`未注册端点 ${endpointId}`, { code: 'ENDPOINT_NOT_REGISTERED' }); + const traceId = crypto.randomUUID(); + const headers = { Accept: '*/*', 'X-Trace-ID': traceId, ...(options.headers || {}) }; + const token = getAccessToken(); + if (endpoint.auth !== false && token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${pathFor(endpoint, options.pathParams)}${new URLSearchParams(options.query || {}).toString() ? `?${new URLSearchParams(options.query).toString()}` : ''}`, { + method: endpoint.method, + headers, + cache: 'no-store', + signal: options.signal, + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const detail = payload.error || {}; + if (response.status === 401 && endpoint.auth !== false) clearAuthSession(); + throw new ApiError(detail.message || `文件请求失败(HTTP ${response.status})`, { + code: detail.code, + status: response.status, + traceId: payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId, + }); + } + return { + blob: await response.blob(), + filename: response.headers.get('Content-Disposition') || '', + contentType: response.headers.get('Content-Type') || '', + traceId: response.headers.get('X-Trace-ID') || traceId, + }; +} + export const apiClient = Object.freeze({ get(endpointId, options = {}) { return request(endpointId, options); }, post(endpointId, body, options = {}) { return request(endpointId, { ...options, body }); }, upload(endpointId, formData, options = {}) { return request(endpointId, { ...options, body: formData, timeout: options.timeout || 30000 }); }, + file(endpointId, options = {}) { return requestFile(endpointId, options); }, stream, reportError(error) { window.dispatchEvent(new CustomEvent('portal:error', { detail: { message: error.message, traceId: error.traceId || '' } })); diff --git a/app/static/portal/common/formatters.js b/app/static/portal/common/formatters.js index 8b38886..2526077 100644 --- a/app/static/portal/common/formatters.js +++ b/app/static/portal/common/formatters.js @@ -12,12 +12,18 @@ export function formatPercent(value) { return `${number > 0 ? '+' : ''}${number.toFixed(2)}%`; } -export function formatDateTime(value) { +export function formatDateTime(value, includeSeconds = false) { if (!value) return '--'; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + ...(includeSeconds ? { second: '2-digit' } : {}), + hour12: false, }).format(date).replaceAll('/', '-'); } diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js index 1574a12..4db5f7e 100644 --- a/app/static/portal/common/layout/app-shell.js +++ b/app/static/portal/common/layout/app-shell.js @@ -33,6 +33,9 @@ const ADVISOR_LINKS = [ const OPERATOR_LINKS = [ ['operator-dashboard', '运营工作台', '/portal/employee-operations/dashboard/'], + ['operator-offsite', '场外申赎', '/portal/employee-operations/offsite/'], + ['operator-promotion', '推介材料', '/portal/employee-operations/promotion/'], + ['operator-nl2sql', 'NL2SQL', '/portal/employee-operations/nl2sql/'], ['public-products', '公开产品', '/portal/guest/products/'], ]; @@ -121,19 +124,26 @@ export function mountShell({ active, mode = 'public' }) { window.location.assign('/portal/guest/home/?reason=signed-out'); }); const networkStatus = document.querySelector('[data-network-status]'); - window.addEventListener('offline', () => { - networkStatus.textContent = '网络已断开'; + let networkStatusTimer = 0; + const hideNetworkStatus = () => { + window.clearTimeout(networkStatusTimer); + networkStatus.classList.remove('network-status--visible'); + }; + const showNetworkStatus = (message, duration = 3600) => { + window.clearTimeout(networkStatusTimer); + networkStatus.textContent = message; networkStatus.classList.add('network-status--visible'); + if (duration > 0) networkStatusTimer = window.setTimeout(hideNetworkStatus, duration); + }; + window.addEventListener('offline', () => { + showNetworkStatus('网络已断开', 0); }); window.addEventListener('online', () => { - networkStatus.textContent = '网络已恢复,请点击刷新'; - networkStatus.classList.add('network-status--visible'); - window.setTimeout(() => networkStatus.classList.remove('network-status--visible'), 3000); + showNetworkStatus('网络已恢复,请点击刷新', 3000); }); window.addEventListener('portal:error', (event) => { const detail = event.detail || {}; - networkStatus.textContent = detail.message || '请求未完成'; - networkStatus.classList.add('network-status--visible'); + showNetworkStatus(detail.message || '请求未完成'); }); if (new URLSearchParams(window.location.search).get('reason') === 'signed-out') { networkStatus.textContent = '您已安全退出登录'; diff --git a/app/static/portal/employee-operations/dashboard/dashboard.css b/app/static/portal/employee-operations/dashboard/dashboard.css index 12102c5..2b1495e 100644 --- a/app/static/portal/employee-operations/dashboard/dashboard.css +++ b/app/static/portal/employee-operations/dashboard/dashboard.css @@ -9,4 +9,12 @@ .operator-status__card { padding: var(--space-4); background: var(--surface-soft); border-radius: var(--radius-sm); } .operator-status__card strong { display: block; margin-bottom: 5px; font-size: 18px; } .operator-status__card p { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; } +.operator-module-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); } +.operator-module-card { min-height: 148px; padding: var(--space-5); display: grid; align-content: start; gap: var(--space-2); color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-card); transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; } +.operator-module-card:hover, .operator-module-card:focus-visible { color: var(--ink); border-color: var(--brand); outline: 0; transform: translateY(-2px); box-shadow: var(--shadow-elevated); } +.operator-module-card__index { color: var(--brand-dark); font: 700 var(--fs-small)/1 Consolas, monospace; letter-spacing: .08em; } +.operator-module-card strong { font-size: 18px; } +.operator-module-card > span:last-child { color: var(--muted); font-size: var(--fs-small); line-height: 1.6; } +.heading-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; } @media (max-width: 760px) { .operator-grid { grid-template-columns: 1fr; } } +@media (max-width: 860px) { .operator-module-grid { grid-template-columns: 1fr; } } diff --git a/app/static/portal/employee-operations/dashboard/index.html b/app/static/portal/employee-operations/dashboard/index.html index 07ce254..c9e0d04 100644 --- a/app/static/portal/employee-operations/dashboard/index.html +++ b/app/static/portal/employee-operations/dashboard/index.html @@ -1,3 +1,3 @@ 运营工作台 · 南方财富 -

场外运营与资料处理

运营工作台

集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。

邮件收件识别队列规则核对
运营人员权限加载中

场外基金收件箱

仅展示未删除的运营邮件

运行状态

收件游标与识别监控

+

场外运营与资料处理

运营工作台

集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。

邮件收件识别队列规则核对
运营人员权限加载中
01场外申购和赎回邮件、识别字段、规则核对、通知与清算统计02推介材料生成结构化资料、附件、生成、合规、审核与交付03NL2SQL场外单据核对与通用只读自然语言查询

场外基金收件箱

仅展示未删除的运营邮件

运行状态

收件游标与识别监控

diff --git a/app/static/portal/employee-operations/nl2sql/index.html b/app/static/portal/employee-operations/nl2sql/index.html new file mode 100644 index 0000000..886feef --- /dev/null +++ b/app/static/portal/employee-operations/nl2sql/index.html @@ -0,0 +1,35 @@ + + + + + + NL2SQL · 运营工作台 + + + + + +
+
+

数据运营 · 只读查询链路

NL2SQL

支持场外单据核对和通用金融自然语言查询,查询、权限、审计和最终状态均由后端 Agent 链路决定。

只读查询白名单表权限审计
+
运营人员权限加载中
+
+
+
+
+
+

场外单据核对

先生成并确认自然语言,再执行只读核对。

返回
+

核对结果

查询结果写回场外单据链路后,返回规则摘要。

等待执行
输入 task_id 后执行一次只读核对。
+
+
+ +
+
+ + + diff --git a/app/static/portal/employee-operations/nl2sql/nl2sql.js b/app/static/portal/employee-operations/nl2sql/nl2sql.js new file mode 100644 index 0000000..1dcfb1a --- /dev/null +++ b/app/static/portal/employee-operations/nl2sql/nl2sql.js @@ -0,0 +1,136 @@ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { getAuthContext, requireOperator } from '/static/portal/common/auth.js'; +import { escapeHtml } from '/static/portal/common/formatters.js'; +import { mountShell } from '/static/portal/common/layout/app-shell.js'; +import { showToast } from '/static/portal/common/notifications.js'; + +if (requireOperator()) { + mountShell({ active: 'operator-nl2sql', mode: 'operator' }); + const context = getAuthContext(); + const operatorId = String(context?.userId || context?.username || ''); + const state = { + offsiteTask: new URLSearchParams(location.search).get('task_id') || '', + offsiteNaturalLanguage: '', + offsiteResult: null, + generalRun: null, + timer: null, + }; + const offsiteTask = document.querySelector('[data-offsite-task]'); + const offsiteNaturalLanguage = document.querySelector('[data-offsite-natural-language]'); + const offsiteResult = document.querySelector('[data-offsite-result]'); + const generalResult = document.querySelector('[data-general-result]'); + offsiteTask.value = state.offsiteTask; + document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员'; + document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`; + const backLink = document.querySelector('[data-offsite-back]'); + + const STATUS_LABELS = { + planned: '核对完成', + query_failed: '查询失败', + success: '查询成功', + error: '查询失败', + pending: '待执行', + running: '执行中', + }; + + const RULE_LABELS = { + subscription_holding_ratio: '申购后单一投资者持有比例', + subscription_single_share_limit: '申购单笔份额上限', + redemption_large_ratio: '赎回巨额比例', + redemption_available_quantity: '账户可用份额', + }; + + function statusLabel(status) { + return STATUS_LABELS[String(status || '').toLowerCase()] || status || '等待执行'; + } + + function ruleLabel(item) { + return item.rule_name || RULE_LABELS[item.rule_code] || item.rule_code || '查询规则'; + } + + function statusTag(status) { return `${escapeHtml(status || '等待执行')}`; } + function buildNaturalLanguage(fields) { + const fundCode = String(fields?.fund_code || '').trim(); + if (!fundCode) throw new Error('当前单据缺少基金代码,无法生成自然语言'); + let base = `基金代码为${fundCode}`; + const accountIdentifier = String(fields?.account_identifier || '').trim(); + if (accountIdentifier) base += `,账户标识为${accountIdentifier}`; + if (fields.document_type === 'subscription') { + return [ + `${base},查询基金最新总份额、最新净值和申请前持有份额`, + `${base},查询基金最新总份额和最新净值`, + ].join('\n'); + } + return [ + `${base},查询产品最新总份额`, + `${base},查询账户当前最新可用份额`, + ].join('\n'); + } + function renderOffsite() { + const result = state.offsiteResult; + offsiteNaturalLanguage.value = state.offsiteNaturalLanguage; + if (backLink) { + backLink.href = state.offsiteTask + ? `/portal/employee-operations/offsite/?task_id=${encodeURIComponent(state.offsiteTask)}` + : '/portal/employee-operations/offsite/'; + } + document.querySelector('[data-offsite-status]').outerHTML = `${escapeHtml(statusLabel(result?.status))}`; + offsiteResult.innerHTML = result ? `
单据
${escapeHtml(result.task_id || state.offsiteTask)}
执行状态
${statusTag(statusLabel(result.status))}
查询数量
${escapeHtml(String(result.queries?.length || 0))}
返回说明
${escapeHtml(result.message || '已完成服务端核对')}
${(result.queries || []).map((item) => ``).join('') || ''}
核对规则状态返回行数错误信息
${escapeHtml(ruleLabel(item))}${escapeHtml(item.rule_code || '')}${escapeHtml(statusLabel(item.status))}${escapeHtml(String(item.row_count ?? 0))}${escapeHtml(item.error_message || '--')}
暂无查询记录。
${escapeHtml(JSON.stringify(result, null, 2))}
` : '
输入 task_id 后执行一次只读核对。
'; + } + function renderGeneral() { + const run = state.generalRun; + document.querySelector('[data-general-status]').textContent = run?.status || '等待执行'; + generalResult.innerHTML = run ? `
运行编号
${escapeHtml(run.run_id)}
Agent 类型
${escapeHtml(run.agent_type || '--')}
状态
${escapeHtml(run.status)}
错误码
${escapeHtml(run.error_code || '--')}
${escapeHtml(JSON.stringify(run.result || run, null, 2))}
` : '
提交问题后,前端会自动轮询运行状态。
'; + } + async function poll(runId) { + if (state.timer) clearTimeout(state.timer); + const response = await apiClient.get('AGENT_RUN', { pathParams: { runId } }); + state.generalRun = response.data; renderGeneral(); + if (['queued', 'running', 'processing', 'pending'].includes(String(state.generalRun.status).toLowerCase())) { + state.timer = window.setTimeout(() => poll(runId).catch((error) => showToast(error.message, 'error')), 1200); + } else showToast(state.generalRun.status === 'succeeded' ? '通用查询已完成' : `查询状态:${state.generalRun.status}`, state.generalRun.status === 'succeeded' ? 'success' : 'error'); + } + async function run(action) { + try { + if (action === 'generate-offsite') { + const taskId = offsiteTask.value.trim(); + if (!taskId) throw new Error('请输入单据 task_id'); + const response = await apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }); + state.offsiteTask = taskId; + state.offsiteNaturalLanguage = buildNaturalLanguage(response.data); + renderOffsite(); + showToast('自然语言已生成,可人工修改'); + return; + } + if (action === 'run-offsite') { + const taskId = offsiteTask.value.trim(); + if (!taskId) throw new Error('请输入单据 task_id'); + if (!document.querySelector('[data-manual-confirmed]').checked) throw new Error('请先确认原始文件内容'); + const response = await apiClient.post('OFFSITE_TRIGGER_NL2SQL', { operator_id: operatorId, manual_confirmed: true }, { pathParams: { taskId } }); + state.offsiteResult = response.data; renderOffsite(); showToast(response.data?.status === 'query_failed' ? '核对完成,但存在查询失败' : '只读核对已完成'); return; + } + if (action === 'run-general') { + const message = document.querySelector('[data-query-text]').value.trim(); + if (!message) throw new Error('请输入业务问题'); + const sessionId = document.querySelector('[data-session-id]').value.trim() || `portal-${Date.now()}`; + document.querySelector('[data-session-id]').value = sessionId; + const response = await apiClient.post('AGENT_RUN_CREATE', { agent_type: document.querySelector('[data-agent-type]').value, message, session_id: sessionId, idempotency_key: crypto.randomUUID().replaceAll('-', '') }); + state.generalRun = response.data; renderGeneral(); showToast(`查询已提交:${response.data.run_id}`); poll(response.data.run_id); return; + } + } catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); } + } + document.querySelectorAll('[data-tab]').forEach((tab) => tab.addEventListener('click', () => { + document.querySelectorAll('[data-tab]').forEach((item) => item.setAttribute('aria-selected', String(item === tab))); + document.querySelectorAll('[data-view]').forEach((view) => { view.hidden = view.dataset.view !== tab.dataset.tab; }); + })); + document.querySelector('main').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action) run(action); }); + offsiteTask.addEventListener('input', () => { + state.offsiteTask = offsiteTask.value; + state.offsiteNaturalLanguage = ''; + offsiteNaturalLanguage.value = ''; + }); + offsiteNaturalLanguage.addEventListener('input', () => { + state.offsiteNaturalLanguage = offsiteNaturalLanguage.value; + }); + renderOffsite(); renderGeneral(); +} diff --git a/app/static/portal/employee-operations/offsite/index.html b/app/static/portal/employee-operations/offsite/index.html new file mode 100644 index 0000000..ce76e1a --- /dev/null +++ b/app/static/portal/employee-operations/offsite/index.html @@ -0,0 +1,58 @@ + + + + + + 场外申购和赎回 · 运营工作台 + + + + + +
+
+
+

场外运营 · 业务邮件处理

+

场外申购和赎回

+

从邮件、附件识别到规则核对、人工确认、通知发送和清算统计,所有动作均以服务端状态和权限为准。

+
邮件收件OCR 与 NL2SQL规则确认
+
+
运营人员权限加载中
+
+
+
+
+
+ + +
+
+
+
+

业务邮件列表

+
+
+
+
+
+

邮件详情与附件

选择邮件后读取识别字段和关联单据

+
+
+
+
+
+

单据核对与运营动作

识别字段、NL2SQL 字段和规则结果分别来自独立后端接口

+
+
+ +
+

清算统计

只统计已确认正常且正常返回通知发送成功的单据

+
+
+
+ + + diff --git a/app/static/portal/employee-operations/offsite/offsite.js b/app/static/portal/employee-operations/offsite/offsite.js new file mode 100644 index 0000000..464b075 --- /dev/null +++ b/app/static/portal/employee-operations/offsite/offsite.js @@ -0,0 +1,529 @@ +import { apiClient } from '/static/portal/common/api-client.js?v=20260915'; +import { getAuthContext, requireOperator } from '/static/portal/common/auth.js'; +import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; +import { mountShell } from '/static/portal/common/layout/app-shell.js'; +import { showToast } from '/static/portal/common/notifications.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; + +const PREVIEW_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif']); +const NOTIFICATION_TYPES = [['mail_return', '邮件回执'], ['normal_return', '正常回执'], ['exception_return', '异常回执'], ['risk', '风险通知'], ['settlement', '清算通知']]; +const NOTIFICATION_LABELS = Object.fromEntries(NOTIFICATION_TYPES); +const MAIL_STATUS_FILTERS = [ + ['exception', '有异常'], + ['processed', '已处理'], + ['processing', '处理中'], + ['inbox', '已入库'], + ['replied', '已回执'], + ['all', '全部邮件'], +]; + +if (requireOperator()) { + mountShell({ active: 'operator-offsite', mode: 'operator' }); + const context = getAuthContext(); + const operatorId = String(context?.userId || context?.username || ''); + const state = { + page: 1, pageSize: 10, total: 0, mails: [], mailbox: null, activeMailStatus: 'all', + mailDetails: {}, + selectedMailId: '', mail: null, recognition: null, documents: [], nl2sql: {}, rules: {}, + ocrDrafts: {}, nlDrafts: {}, notice: null, noticeTaskId: '', noticeDraft: '', stats: null, + statsForm: { fundCode: '', applicationDate: '' }, + recalculatingTasks: new Set(), + }; + const root = document.querySelector('main'); + const targets = { + metrics: document.querySelector('[data-offsite-metrics]'), + statusTabs: document.querySelector('[data-mail-status-tabs]'), + list: document.querySelector('[data-mail-list]'), + pagination: document.querySelector('[data-mail-pagination]'), + detail: document.querySelector('[data-mail-detail]'), + documents: document.querySelector('[data-document-list]'), + noticePanel: document.querySelector('[data-notification-panel]'), + notice: document.querySelector('[data-notification]'), + statistics: document.querySelector('[data-statistics]'), + }; + document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员'; + document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`; + + function value(item, fallback = '--') { + if (item === null || item === undefined || item === '') return fallback; + if (Array.isArray(item)) return item.length ? item.join('、') : fallback; + if (typeof item === 'object') return JSON.stringify(item); + return String(item); + } + + function tag(text, tone = '') { + return `${escapeHtml(text || '--')}`; + } + + function actionButton(label, action, tone = '', disabled = false) { + return ``; + } + + function fieldStatusLabel(status) { + return { + success: '成功', + query_failed: '失败', + not_queried: '未查询', + pending: '待查询', + corrected: '人工修正', + }[String(status || '').toLowerCase()] || '未查询'; + } + + function documentNotificationType(document) { + if (document?.operator_decision === '确认正常') return 'normal_return'; + if (document?.operator_decision === '确认异常') return 'exception_return'; + return ''; + } + + function mailDocuments(mail) { + return (mail?.attachments || []).flatMap((attachment) => attachment.documents || []); + } + + function mailDisplayStatus(mail) { + const internalStatus = String(mail?.status || ''); + const documents = mailDocuments(state.mailDetails[mail?.mail_id]); + const decisions = documents.map((item) => item.operator_decision).filter(Boolean); + + if (internalStatus === 'processing') { + if (decisions.includes('确认异常')) return { key: 'exception', label: '有异常', tone: 'high' }; + if (decisions.length && decisions.every((decision) => decision === '确认正常')) { + return { key: 'processed', label: '已处理', tone: 'low' }; + } + return { key: 'processing', label: '处理中', tone: 'medium' }; + } + if (internalStatus === 'recognized' || internalStatus === 'received') { + return { key: 'inbox', label: '已入库', tone: 'active' }; + } + if (internalStatus === 'normal_return_sent' || internalStatus === 'completed') { + return { key: 'replied', label: '已回执', tone: 'low' }; + } + if (internalStatus === 'deleted') return { key: 'deleted', label: '已删除', tone: 'neutral' }; + return { key: 'processing', label: '处理中', tone: 'medium' }; + } + + function documentDisplayStatus(document) { + if (document.operator_decision === '确认异常') return { label: '有异常', tone: 'high' }; + if (document.operator_decision === '确认正常') return { label: '已处理', tone: 'low' }; + if (document.status === 'recognized' || document.status === 'planned') { + return { label: '已入库', tone: 'active' }; + } + return { label: '处理中', tone: 'medium' }; + } + + function ruleComparison(row) { + const calculation = row?.calculation || {}; + if (calculation.实际值 !== undefined && calculation.规则值 !== undefined) { + return { + actual: calculation.实际值, + rule: calculation.规则值, + expression: calculation.比较, + }; + } + if (row?.rule_code === 'subscription_minimum_amount') { + return { + actual: row.document_value?.申购金额元, + rule: '> 1 元', + expression: row.document_value?.申购金额元 === undefined + ? '' + : `${row.document_value.申购金额元} > 1 元`, + }; + } + if (calculation.申购后持有比例 !== undefined) { + const actual = `${Number(calculation.申购后持有比例) * 100}%`; + return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` }; + } + if (calculation.本次申购份额 !== undefined && calculation.份额上限 !== undefined) { + return { + actual: calculation.本次申购份额, + rule: calculation.份额上限, + expression: `${calculation.本次申购份额} ≤ ${calculation.份额上限}`, + }; + } + if (calculation.赎回比例 !== undefined) { + const actual = `${Number(calculation.赎回比例) * 100}%`; + return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` }; + } + if (row?.rule_code === 'redemption_available_quantity') { + return { + actual: row.document_value?.赎回份额, + rule: row.database_value?.当前最新可用份额, + expression: row.document_value?.赎回份额 === undefined + || row.database_value?.当前最新可用份额 === undefined + ? '' + : `${row.document_value.赎回份额} ≤ ${row.database_value.当前最新可用份额}`, + }; + } + return { actual: '', rule: '', expression: '' }; + } + + function renderRuleComparison(row) { + const comparison = ruleComparison(row); + if (!comparison.actual && !comparison.rule) { + return '暂无可对比数据'; + } + const conclusion = row.result === '正常' ? '满足规则' : row.result === '异常' ? '不满足规则' : '无法判断'; + const tone = row.result === '正常' ? 'operator-comparison--normal' : row.result === '异常' ? 'operator-comparison--abnormal' : 'operator-comparison--unknown'; + return `
+
+
实际值${escapeHtml(value(comparison.actual))}
+
规则值${escapeHtml(value(comparison.rule))}
+
+ ${comparison.expression ? `
${escapeHtml(comparison.expression)} · ${conclusion}
` : `
${conclusion}
`} +
`; + } + + function renderStatusTabs() { + targets.statusTabs.innerHTML = MAIL_STATUS_FILTERS.map(([key, label]) => ` + + `).join(''); + } + + function errorText(error) { + return `
${escapeHtml(error?.message || '请求未完成')}
`; + } + + function renderMetrics() { + const selectedDocs = state.documents.length; + const pending = state.documents.filter((item) => item.operator_decision === '未处理' || !item.operator_decision).length; + const blocked = Boolean(state.mailbox?.blocked); + targets.metrics.innerHTML = [ + ['业务邮件', state.total, `当前第 ${state.page} 页`], + ['当前单据', selectedDocs, state.selectedMailId ? '来自当前邮件' : '请选择邮件'], + ['待人工确认', pending, '以服务端单据状态为准'], + ['收件状态', blocked ? '已阻塞' : (state.mailbox?.monitoring ? '运行中' : '未启用'), blocked ? '需要恢复游标' : '服务端状态'], + ].map(([label, current, meta]) => `

${label}

${escapeHtml(String(current))}

${escapeHtml(meta)}

`).join(''); + } + + function renderMailList() { + const visibleMails = state.mails.filter((mail) => ( + state.activeMailStatus === 'all' || mailDisplayStatus(mail).key === state.activeMailStatus + )); + if (!visibleMails.length) { + const activeLabel = MAIL_STATUS_FILTERS.find(([key]) => key === state.activeMailStatus)?.[1] || '业务'; + renderEmpty(targets.list, `暂无${activeLabel}`, '当前状态下没有可展示的场外基金邮件。'); + targets.pagination.innerHTML = ''; + return; + } + targets.list.innerHTML = visibleMails.map((mail) => { + const active = mail.mail_id === state.selectedMailId; + const displayStatus = mailDisplayStatus(mail); + return `
+
${escapeHtml(mail.subject || mail.mail_id || '无主题邮件')}${escapeHtml(mail.sender || '未知发件人')}发送日期:${escapeHtml(formatDateTime(mail.sent_at || mail.received_at || mail.received_date))}
+
${tag(displayStatus.label, displayStatus.tone)}${actionButton('删除', `delete-mail:${encodeURIComponent(mail.mail_id)}`, 'danger')}
+
`; + }).join(''); + const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); + targets.pagination.innerHTML = `第 ${state.page} / ${pages} 页,共 ${state.total} 封${actionButton('上一页', 'page:prev', '')}${actionButton('下一页', 'page:next', '')}`; + targets.pagination.querySelector('[data-action="page:prev"]')?.toggleAttribute('disabled', state.page <= 1); + targets.pagination.querySelector('[data-action="page:next"]')?.toggleAttribute('disabled', state.page >= pages); + } + + function fieldNames(base, effective, extra = []) { + const names = []; + [...Object.keys(base || {}), ...Object.keys(effective || {}), ...extra].forEach((name) => { + if (name && !names.includes(name)) names.push(name); + }); + return names; + } + + function renderFieldGrid(fields, draft, scope, key, statuses = {}) { + const names = fieldNames(fields, draft); + if (!names.length) return '
暂无可展示字段。
'; + return `
${names.map((name) => ``).join('')}
`; + } + + function renderDetail() { + if (!state.mail) { + renderEmpty(targets.detail, '请选择一封邮件', '邮件详情会展示正文、附件原件、OCR 识别字段和关联单据。'); + return; + } + const attachments = state.mail.attachments || []; + targets.detail.innerHTML = `
邮件编号
${escapeHtml(value(state.mail.mail_id))}
发件人
${escapeHtml(value(state.mail.sender))}
接收时间
${escapeHtml(formatDateTime(state.mail.received_at || state.mail.received_date))}
+

邮件正文

${escapeHtml(state.mail.body_text || state.mail.body_html || '暂无正文')}
+

附件原件

${attachments.length} 个附件
${attachments.map(renderAttachment).join('') || '
暂无附件。
'}
`; + } + + function renderAttachment(item) { + const docs = item.documents || []; + const itemRecognition = (state.recognition?.attachments || []).find((row) => row.attachment_id === item.attachment_id); + const draft = state.ocrDrafts[item.attachment_id] || {}; + const missing = itemRecognition?.missing_fields || []; + const names = fieldNames(itemRecognition?.extracted_fields, itemRecognition?.effective_fields, missing); + return `
${escapeHtml(item.filename || item.attachment_id)}

OCR ${escapeHtml(itemRecognition?.ocr_status || '未记录')}

${actionButton(PREVIEW_TYPES.has(String(item.media_type || '').toLowerCase()) ? '预览原件' : '下载原件', `file:${encodeURIComponent(item.attachment_id)}`)}
${missing.length ? `
缺失字段:${escapeHtml(missing.join('、'))}
` : ''}

OCR 识别字段

${renderFieldGrid(itemRecognition?.effective_fields || itemRecognition?.extracted_fields, draft, 'ocr', item.attachment_id, itemRecognition?.field_confidence || {})}
${actionButton('保存', `save-ocr:${encodeURIComponent(item.attachment_id)}`, 'primary')}${actionButton('重试', docs[0]?.task_id ? `retry:${encodeURIComponent(docs[0].task_id)}` : 'noop')}
`; + } + + function renderDocuments() { + if (!state.selectedMailId) { + renderEmpty(targets.documents, '请选择邮件后处理单据', '单据来自邮件附件关联记录。'); + return; + } + if (!state.documents.length) { + renderEmpty(targets.documents, '当前邮件没有业务单据', '如果附件识别异常,可在上方查看原件并提交识别重试。'); + return; + } + targets.documents.innerHTML = state.documents.map((document) => { + const taskId = document.task_id; + const nl = state.nl2sql[taskId]; + const rule = state.rules[taskId]; + const draft = state.nlDrafts[taskId] || {}; + const ruleRows = rule?.rules || []; + const displayStatus = documentDisplayStatus(document); + const notificationType = documentNotificationType(document); + const recalculating = state.recalculatingTasks.has(taskId); + const encodedTaskId = encodeURIComponent(String(taskId || '').trim()); + const notificationAction = notificationType + ? `notice:${encodedTaskId}:${notificationType}` + : `notice:${encodedTaskId}`; + return `
${escapeHtml(taskId)}

${escapeHtml(document.document_type || '单据')} · ${escapeHtml(document.fund_name || document.fund_code || '--')} · ${escapeHtml(document.application_no || '--')}

${tag(document.operator_decision || displayStatus.label, document.operator_decision === '确认异常' ? 'high' : displayStatus.tone)}
申请日期
${escapeHtml(value(document.application_date))}
申购金额 / 赎回份额
${escapeHtml(value(document.subscription_amount_yuan))} / ${escapeHtml(value(document.redemption_shares))}
机构
${escapeHtml(value(document.agency))}
基金代码
${escapeHtml(value(document.fund_code))}
+

NL2SQL 返回字段

${escapeHtml(nl?.updated_at ? formatDateTime(nl.updated_at, true) : '尚未核对')}
${nl?.error ? errorText({ message: nl.error }) : renderFieldGrid(nl?.effective_fields || nl?.fields, draft, 'nl', taskId, nl?.field_status || {})}
${actionButton('保存', `save-nl:${encodeURIComponent(taskId)}`, 'primary')}${actionButton('进入 NL2SQL', `open-nl:${encodeURIComponent(taskId)}`)}
+

规则结果

${rule ? tag(rule.document_status || '已读取') : ''}
${rule?.error ? errorText({ message: rule.error }) : `
${ruleRows.map((row) => ``).join('') || ''}
规则结果单据值实际值 / 规则值
${escapeHtml(row.rule_name || row.rule_code || '--')}${tag(row.result)}${escapeHtml(value(row.document_value))}${renderRuleComparison(row)}
暂无规则结果,请先执行 NL2SQL 核对。
`}
${actionButton(recalculating ? '正在核对并判定...' : '重新核对并判定规则', `recalculate:${encodeURIComponent(taskId)}`, 'primary', recalculating)}${actionButton('确认正常', `confirm:${encodeURIComponent(taskId)}:确认正常`)}${actionButton('确认异常', `confirm:${encodeURIComponent(taskId)}:确认异常`, 'danger')}${actionButton(notificationType ? `创建${NOTIFICATION_LABELS[notificationType]}` : '创建通知', notificationAction)}
`; + }).join(''); + } + + function renderNotice() { + if (!state.notice) { + targets.noticePanel.hidden = true; + return; + } + targets.noticePanel.hidden = false; + targets.notice.innerHTML = `
${actionButton('发送通知', 'send-notice', 'primary')}${actionButton('清除通知', 'clear-notice')}
`; + } + + function renderStatistics() { + const items = Array.isArray(state.stats?.items) + ? state.stats.items + : state.stats ? [state.stats] : []; + const blocks = items.map((item) => `

${escapeHtml(value(item.fund_name, '未识别基金'))}

基金代码:${escapeHtml(value(item.fund_code))}

${escapeHtml(value(item.application_date))}
申购金额 / 笔数
${escapeHtml(value(item.subscription_amount_yuan, '0'))} / ${escapeHtml(value(item.subscription_count, '0'))}
赎回份额 / 金额
${escapeHtml(value(item.redemption_shares, '0'))} / ${escapeHtml(value(item.redemption_amount_yuan))}
净流入 / 流出
${escapeHtml(value(item.net_flow_amount_yuan))}
最新净值
${escapeHtml(value(item.latest_nav))}
`).join(''); + targets.statistics.innerHTML = `
${state.stats ? (items.length ? `

共 ${items.length} 只基金,每只基金单独汇总。

${blocks}
` : '
当天没有符合条件的清算单据。
') : '

基金代码留空时,将统计申请日期当天全部基金。

'}`; + } + + function render() { + renderMetrics(); renderStatusTabs(); renderMailList(); renderDetail(); renderDocuments(); renderNotice(); renderStatistics(); + } + + function syncDocumentsFromRecognition(payload) { + const attachments = Array.isArray(payload?.attachments) ? payload.attachments : []; + if (!attachments.length || !state.mail) return; + const documentsByAttachment = new Map( + attachments.map((attachment) => [ + attachment.attachment_id, + (attachment.documents || []).map((document) => ({ + ...document, + attachment_id: attachment.attachment_id, + })), + ]), + ); + state.mail = { + ...state.mail, + attachments: (state.mail.attachments || []).map((attachment) => ({ + ...attachment, + documents: documentsByAttachment.get(attachment.attachment_id) + || attachment.documents + || [], + })), + }; + state.mailDetails[state.mail.mail_id] = state.mail; + state.documents = state.mail.attachments.flatMap((attachment) => ( + attachment.documents || [] + )); + } + + async function loadMail(mailId) { + const [mailResponse, recognitionResponse] = await Promise.all([ + apiClient.get('OFFSITE_MAIL', { pathParams: { mailId } }), + apiClient.get('OFFSITE_RECOGNITION', { pathParams: { mailId } }).catch((error) => ({ data: { error: error.message, attachments: [] } })), + ]); + state.selectedMailId = mailId; + state.mail = mailResponse.data || {}; + state.mailDetails[mailId] = state.mail; + state.recognition = recognitionResponse.data || {}; + state.ocrDrafts = Object.fromEntries((state.recognition.attachments || []).map((item) => [item.attachment_id, { ...(item.effective_fields || item.extracted_fields || {}) }])); + state.documents = (state.mail.attachments || []).flatMap((attachment) => (attachment.documents || []).map((document) => ({ ...document, attachment_id: document.attachment_id || attachment.attachment_id }))); + const taskIds = [...new Set(state.documents.map((item) => item.task_id).filter(Boolean))]; + const results = await Promise.all(taskIds.map(async (taskId) => { + const [nlResult, ruleResult] = await Promise.allSettled([ + apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }), + apiClient.get('OFFSITE_RULE_RESULTS', { pathParams: { taskId } }), + ]); + return { taskId, nl: nlResult.status === 'fulfilled' ? nlResult.value.data : { error: nlResult.reason?.message || '读取失败' }, rule: ruleResult.status === 'fulfilled' ? ruleResult.value.data : { error: ruleResult.reason?.message || '读取失败' } }; + })); + state.nl2sql = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, nl])); + state.nlDrafts = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, { ...(nl.effective_fields || nl.fields || {}) }])); + state.rules = Object.fromEntries(results.map(({ taskId, rule }) => [taskId, rule])); + const first = state.documents.find((item) => item.fund_code && item.application_date); + if (first) { + state.statsForm.applicationDate = String(first.application_date).slice(0, 10); + } + render(); + } + + async function loadMailStatusDetails(mails) { + const candidates = mails.filter((mail) => String(mail.status || '') === 'processing'); + await Promise.allSettled(candidates.map(async (mail) => { + const response = await apiClient.get('OFFSITE_MAIL', { pathParams: { mailId: mail.mail_id } }); + state.mailDetails[mail.mail_id] = response.data || {}; + })); + } + + async function load() { + renderLoading(targets.list, 5); renderLoading(targets.detail, 3); renderLoading(targets.documents, 3); + try { + const [mails, mailbox] = await Promise.all([ + apiClient.get('OFFSITE_MAILS', { query: { page: state.page, page_size: state.pageSize } }), + apiClient.get('OFFSITE_MAILBOX'), + ]); + const payload = mails.data || {}; + state.mails = Array.isArray(payload.items) ? payload.items : []; + state.total = Number(payload.total || 0); + state.mailbox = mailbox.data || {}; + await loadMailStatusDetails(state.mails); + if (!state.selectedMailId || !state.mails.some((item) => item.mail_id === state.selectedMailId)) { + state.selectedMailId = state.mails[0]?.mail_id || ''; + } + if (state.selectedMailId) await loadMail(state.selectedMailId); + else { state.mail = null; state.documents = []; render(); } + } catch (error) { + apiClient.reportError(error); renderError(targets.list, error, load); renderError(targets.detail, error, load); renderError(targets.documents, error, load); + } + } + + async function fileAction(attachmentId) { + const result = await apiClient.file('OFFSITE_ATTACHMENT_FILE', { pathParams: { attachmentId }, query: { disposition: 'inline' } }); + const url = URL.createObjectURL(result.blob); + const opened = window.open(url, '_blank', 'noopener,noreferrer'); + if (!opened) { + const link = document.createElement('a'); link.href = url; link.download = attachmentId; link.click(); + } + window.setTimeout(() => URL.revokeObjectURL(url), 60000); + } + + async function run(action) { + try { + if (action === 'refresh') { await load(); showToast('邮件列表已刷新'); return; } + if (action.startsWith('select-mail:')) { + await loadMail(decodeURIComponent(action.slice(12))); + return; + } + if (action === 'recover-mailbox') { await apiClient.post('OFFSITE_MAILBOX_RECOVER', { operator_id: operatorId }); await load(); showToast('收件游标已恢复'); return; } + if (action.startsWith('page:')) { const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); state.page = Math.min(pages, Math.max(1, state.page + (action.endsWith('next') ? 1 : -1))); await load(); return; } + if (action.startsWith('delete-mail:')) { + const mailId = decodeURIComponent(action.slice(12)); + if (!window.confirm('确认删除这封邮件吗?删除后只会从运营列表隐藏,原始邮件和识别记录仍保留。')) return; + await apiClient.post('OFFSITE_MAIL_DELETE', { operator_id: operatorId }, { pathParams: { mailId } }); + state.selectedMailId = ''; await load(); showToast('邮件已删除'); return; + } + if (action.startsWith('file:')) { await fileAction(decodeURIComponent(action.slice(5))); return; } + if (action.startsWith('save-ocr:')) { + const attachmentId = decodeURIComponent(action.slice(9)); + const response = await apiClient.post('OFFSITE_RECOGNITION_SAVE', { operator_id: operatorId, attachments: [{ attachment_id: attachmentId, fields: state.ocrDrafts[attachmentId] || {} }] }, { pathParams: { mailId: state.selectedMailId } }); + state.recognition = response.data; + const saved = state.recognition.attachments?.find((item) => item.attachment_id === attachmentId); + state.ocrDrafts[attachmentId] = { ...(saved?.effective_fields || {}) }; + syncDocumentsFromRecognition(state.recognition); + showToast('OCR 识别字段修正已保存'); render(); return; + } + if (action.startsWith('save-nl:')) { + const taskId = decodeURIComponent(action.slice(8)); + const response = await apiClient.post('OFFSITE_NL2SQL_FIELDS_SAVE', { operator_id: operatorId, fields: state.nlDrafts[taskId] || {} }, { pathParams: { taskId } }); + state.nl2sql[taskId] = response.data; + state.nlDrafts[taskId] = { ...(response.data.effective_fields || response.data.fields || {}) }; + showToast('NL2SQL 字段修正已保存'); render(); return; + } + if (action.startsWith('retry:')) { + const taskId = decodeURIComponent(action.slice(6)); + if (!window.confirm('是否重新对该文件进行 OCR 识别?')) return; + await apiClient.post('OFFSITE_RECOGNITION_RETRY', { operator_id: operatorId }, { pathParams: { taskId } }); + await loadMail(state.selectedMailId); showToast('已提交识别重试'); return; + } + if (action.startsWith('recalculate:')) { + const taskId = decodeURIComponent(action.slice(12)); + if (state.recalculatingTasks.has(taskId)) return; + state.recalculatingTasks.add(taskId); + render(); + try { + const triggerResponse = await apiClient.post( + 'OFFSITE_TRIGGER_NL2SQL', + { operator_id: operatorId, manual_confirmed: true }, + { pathParams: { taskId } }, + ); + const [fieldsResponse, rulesResponse] = await Promise.all([ + apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }), + apiClient.post( + 'OFFSITE_RULE_RECALCULATE', + { operator_id: operatorId }, + { pathParams: { taskId } }, + ), + ]); + state.nl2sql[taskId] = fieldsResponse.data; + state.nlDrafts[taskId] = { + ...(fieldsResponse.data?.effective_fields || fieldsResponse.data?.fields || {}), + }; + state.rules[taskId] = rulesResponse.data; + showToast( + triggerResponse.data?.status === 'query_failed' + ? '已重新核对,但存在查询失败' + : '规则已重新核对并判定', + triggerResponse.data?.status === 'query_failed' ? 'error' : 'success', + ); + } finally { + state.recalculatingTasks.delete(taskId); + render(); + } + return; + } + if (action.startsWith('confirm:')) { + const [, encodedTask, decision] = action.split(':'); + const taskId = decodeURIComponent(encodedTask).trim(); + await apiClient.post('OFFSITE_CONFIRM', { decision, operator_id: operatorId }, { pathParams: { taskId } }); + await loadMail(state.selectedMailId); showToast(`单据已${decision}`); return; + } + if (action.startsWith('open-nl:')) { window.location.href = `/portal/employee-operations/nl2sql/?task_id=${encodeURIComponent(decodeURIComponent(action.slice(8)))}`; return; } + if (action.startsWith('notice:')) { + const actionPayload = action.slice(6); + const separator = actionPayload.lastIndexOf(':'); + const encodedTaskId = separator >= 0 ? actionPayload.slice(0, separator) : actionPayload; + const noticeType = separator >= 0 ? actionPayload.slice(separator + 1) : ''; + const taskId = decodeURIComponent(encodedTaskId).trim(); + if (!noticeType) throw new Error('请先确认正常或确认异常后再创建通知'); + const response = await apiClient.post('OFFSITE_NOTIFICATION_CREATE', { notification_type: noticeType, operator_id: operatorId }, { pathParams: { taskId } }); + state.notice = response.data || {}; state.noticeType = noticeType; state.noticeTaskId = taskId; state.noticeDraft = `${taskId} 待发送${NOTIFICATION_LABELS[noticeType]}`; showToast(`${NOTIFICATION_LABELS[noticeType]}已创建`); render(); return; + } + if (action === 'send-notice') { + if (!state.notice?.notification_id) throw new Error('请先创建通知'); + if (!window.confirm('确认发送这条通知吗?发送后将影响业务状态。')) return; + const response = await apiClient.post('OFFSITE_NOTIFICATION_SEND', { operator_id: operatorId, operator_confirmed: true, final_content: state.noticeDraft }, { pathParams: { notificationId: state.notice.notification_id } }); + state.notice = { ...state.notice, ...response.data }; showToast(`通知状态:${response.data?.status || '已提交'}`); render(); return; + } + if (action === 'clear-notice') { state.notice = null; state.noticeTaskId = ''; render(); return; } + if (action === 'statistics') { + if (!state.statsForm.applicationDate) throw new Error('请填写申请日期'); + const response = await apiClient.post('OFFSITE_SETTLEMENT_RECALCULATE', { fund_code: state.statsForm.fundCode || null, application_date: state.statsForm.applicationDate }); + state.stats = response.data; showToast('清算统计已刷新'); render(); return; + } + } catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); } + } + + root.addEventListener('click', (event) => { + const statusKey = event.target.closest('[data-mail-status]')?.dataset.mailStatus; + if (statusKey) { + state.activeMailStatus = statusKey; + render(); + return; + } + const action = event.target.closest('[data-action]')?.dataset.action; + if (action) { event.preventDefault(); run(action); return; } + const mailId = event.target.closest('[data-mail]')?.dataset.mail; + if (mailId) { run(`select-mail:${encodeURIComponent(mailId)}`); } + }); + root.addEventListener('input', (event) => { + const target = event.target; + if (target.matches('[data-ocr-field]')) { state.ocrDrafts[target.dataset.ocrField] ||= {}; state.ocrDrafts[target.dataset.ocrField][target.dataset.fieldName] = target.value; } + if (target.matches('[data-nl-field]')) { state.nlDrafts[target.dataset.nlField] ||= {}; state.nlDrafts[target.dataset.nlField][target.dataset.fieldName] = target.value; } + if (target.matches('[data-stat-field]')) { state.statsForm[target.dataset.statField] = target.value; } + if (target.matches('[data-notice-content]')) state.noticeDraft = target.value; + }); + document.querySelector('[data-mail-refresh]').addEventListener('click', () => run('refresh')); + document.querySelector('[data-mailbox-recover]').addEventListener('click', () => run('recover-mailbox')); + document.querySelector('[data-stat-refresh]').addEventListener('click', () => run('statistics')); + load(); +} diff --git a/app/static/portal/employee-operations/operator-workspace.css b/app/static/portal/employee-operations/operator-workspace.css new file mode 100644 index 0000000..14d3631 --- /dev/null +++ b/app/static/portal/employee-operations/operator-workspace.css @@ -0,0 +1,83 @@ +.operator-workspace { display: grid; gap: var(--space-5); } +.operator-page-heading { display: flex; justify-content: space-between; gap: var(--space-4); align-items: end; } +.operator-page-heading h1 { margin: 0; font-size: clamp(26px, 3vw, 36px); letter-spacing: 0; } +.operator-page-heading p { max-width: 760px; margin: var(--space-2) 0 0; color: var(--muted); line-height: 1.7; } +.operator-page-heading__actions { display: flex; gap: var(--space-2); flex-wrap: wrap; justify-content: flex-end; } +.operator-toolbar { padding: var(--space-4); display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-card); } +.operator-toolbar .form-field { min-width: 150px; flex: 1 1 170px; } +.operator-toolbar__actions { display: flex; gap: var(--space-2); flex-wrap: wrap; } +.operator-mail-status-row { display: flex; align-items: stretch; gap: var(--space-3); min-width: 0; } +.operator-mail-status-tabs { flex: 1 1 auto; min-width: 0; } +.operator-mail-status-actions { display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); padding-bottom: 1px; border-bottom: 1px solid var(--line); } +.operator-columns { display: grid; grid-template-columns: minmax(280px, .8fr) minmax(0, 1.6fr); gap: var(--space-4); align-items: start; } +.operator-stack { min-width: 0; display: grid; gap: var(--space-4); align-content: start; } +.operator-mail-panel > .panel__header, .operator-mail-detail > .panel__header { min-height: 78px; box-sizing: border-box; } +.operator-list { display: grid; } +.operator-list__item { padding: var(--space-4); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; border-bottom: 1px solid var(--line); cursor: pointer; } +.operator-list__item:last-child { border-bottom: 0; } +.operator-list__item:hover, .operator-list__item--active { background: var(--surface-soft); } +.operator-list__item strong, .operator-list__item span { overflow-wrap: anywhere; } +.operator-list__item small { display: block; margin-top: 5px; color: var(--muted); line-height: 1.5; } +.operator-list__actions, .operator-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; } +.operator-actions .button { max-width: 100%; } +.form-field__input--natural-language { min-height: 180px; height: auto; resize: none; line-height: 1.7; padding-top: var(--space-3); padding-bottom: var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: hidden; } +.operator-actions .button--back { flex: 0 1 auto; } +.operator-detail { min-width: 0; } +.operator-detail__body { display: grid; gap: var(--space-4); } +.operator-detail__body > section { padding-top: var(--space-4); border-top: 1px solid var(--line); } +.operator-detail__body > section:first-child { padding-top: 0; border-top: 0; } +.operator-text { margin: 0; max-height: 180px; padding: var(--space-3); overflow: auto; color: var(--ink-soft); background: var(--surface-soft); border-radius: var(--radius-sm); white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 Consolas, monospace; } +.operator-attachment, .operator-document, .operator-field-card, .operator-result-card { padding: var(--space-4); background: var(--surface-soft); border: 1px solid var(--line); border-radius: var(--radius-sm); } +.operator-attachment + .operator-attachment, .operator-document + .operator-document, .operator-field-card + .operator-field-card { margin-top: var(--space-3); } +.operator-attachment__header, .operator-document__header, .operator-section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-3); } +.operator-attachment__header strong, .operator-document__header strong { overflow-wrap: anywhere; } +.operator-meta { margin: var(--space-2) 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; } +.operator-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); margin-top: var(--space-3); } +.operator-field-grid .form-field { min-width: 0; } +.operator-field-label { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); } +.operator-field-confidence { color: var(--muted); font-size: var(--fs-small); font-weight: 500; text-align: right; white-space: nowrap; } +.operator-field-grid .form-field__input { width: 100%; } +.operator-field-grid .form-field__input[readonly] { color: var(--muted); background: var(--surface); } +.operator-subsection { margin-top: var(--space-3); } +.operator-subsection h3, .operator-subsection h4 { margin: 0 0 var(--space-2); font-size: var(--fs-body); } +.operator-kv { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); } +.operator-kv > div { min-width: 0; padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); } +.operator-kv dt { color: var(--muted); font-size: var(--fs-small); } +.operator-kv dd { margin: 4px 0 0; overflow-wrap: anywhere; line-height: 1.5; } +.operator-json { margin: 0; max-height: 360px; padding: var(--space-4); overflow: auto; color: #dcefeb; background: #172624; border-radius: var(--radius-md); font: 12px/1.7 Consolas, monospace; white-space: pre-wrap; } +.operator-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); align-items: start; } +.operator-form-grid--wide { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.operator-form-grid .form-field, .operator-form-grid .form-field__input { min-width: 0; width: 100%; } +.operator-form-grid textarea.form-field__input { height: auto; min-height: 72px; resize: none; overflow-y: hidden; line-height: 1.6; padding-top: var(--space-2); padding-bottom: var(--space-2); } +.operator-checkboxes { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; } +.operator-checkboxes label { display: inline-flex; gap: var(--space-1); align-items: center; color: var(--ink-soft); } +.operator-tabs { display: flex; gap: var(--space-1); overflow-x: auto; border-bottom: 1px solid var(--line); } +.operator-tab { min-height: 48px; padding: 0 var(--space-4); flex: 0 0 auto; border: 0; border-bottom: 2px solid transparent; color: var(--muted); background: transparent; cursor: pointer; } +.operator-tab:hover, .operator-tab:focus-visible, .operator-tab[aria-selected="true"] { color: var(--brand-dark); border-color: var(--brand); outline: 0; } +.operator-view[hidden] { display: none; } +.operator-warning { padding: var(--space-3); color: #885a00; background: #fff5d9; border: 1px solid #ead39a; border-radius: var(--radius-sm); line-height: 1.6; } +.operator-success { padding: var(--space-3); color: #1f6d3b; background: #eaf5ee; border: 1px solid #b9ddc3; border-radius: var(--radius-sm); line-height: 1.6; } +.operator-danger { color: #9c2d20; } +.operator-table-wrap { overflow-x: auto; } +.operator-table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); } +.operator-table th, .operator-table td { padding: var(--space-3); text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); } +.operator-table th { color: var(--muted); font-weight: 650; background: var(--surface-soft); } +.operator-table td { overflow-wrap: anywhere; } +.operator-comparison { min-width: 190px; } +.operator-comparison__values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); } +.operator-comparison__values div { min-width: 0; padding: var(--space-2); background: var(--surface-soft); border-radius: var(--radius-sm); } +.operator-comparison__values span, .operator-comparison__values strong { display: block; } +.operator-comparison__values span { color: var(--muted); font-size: var(--fs-small); } +.operator-comparison__values strong { margin-top: 3px; overflow-wrap: anywhere; } +.operator-comparison__expression { margin-top: var(--space-2); font-size: var(--fs-small); line-height: 1.5; font-weight: 650; } +.operator-comparison--normal .operator-comparison__expression { color: #1f6d3b; } +.operator-comparison--abnormal .operator-comparison__expression { color: #9c2d20; } +.operator-comparison--unknown .operator-comparison__expression { color: #885a00; } +.operator-inline-note { color: var(--muted); font-size: var(--fs-small); line-height: 1.6; } +.operator-file-result { padding: var(--space-3); display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: var(--space-3); border-top: 1px solid var(--line); } +.operator-file-result:first-child { border-top: 0; } +.operator-file-result span { color: var(--muted); } +.operator-file-result code { overflow-wrap: anywhere; white-space: pre-wrap; } +.operator-sticky-actions { position: sticky; bottom: var(--space-3); z-index: 2; padding: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2); background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-menu); backdrop-filter: blur(8px); } +@media (max-width: 900px) { .operator-columns { grid-template-columns: 1fr; } .operator-page-heading { align-items: start; flex-direction: column; } .operator-page-heading__actions { justify-content: flex-start; } .operator-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operator-mail-panel > .panel__header, .operator-mail-detail > .panel__header { min-height: 0; } } +@media (max-width: 620px) { .operator-field-grid, .operator-kv, .operator-form-grid, .operator-form-grid--wide { grid-template-columns: 1fr; } .operator-list__item, .operator-attachment__header, .operator-document__header, .operator-section-heading { grid-template-columns: 1fr; flex-direction: column; } .operator-mail-status-row { flex-wrap: wrap; } .operator-mail-status-actions { width: 100%; justify-content: flex-end; } .operator-file-result { grid-template-columns: 1fr; gap: var(--space-1); } } diff --git a/app/static/portal/employee-operations/promotion/index.html b/app/static/portal/employee-operations/promotion/index.html new file mode 100644 index 0000000..0703879 --- /dev/null +++ b/app/static/portal/employee-operations/promotion/index.html @@ -0,0 +1,45 @@ + + + + + + 推介材料生成 · 运营工作台 + + + + + +
+
+

产品运营 · 资料编排

推介材料生成

维护结构化产品资料,上传来源附件,生成可审核的演示文稿、宣传长图和可选 PDF。

结构化输入材料生成合规审核
+
运营人员权限加载中
+
+

PROMOTION MATERIALS

把产品事实编排成可审核、可交付的材料

+
+

任务基本信息

未创建任务
+
+
+
输出格式
+
+
+
+

产品与管理人资料

+
+

产品信息

+

管理人信息

+

团队与策略

+

费用、业绩与风险

+
+
+
+

业务附件

+
+
+
+

生成、合规、审核与交付

+
+
+
+ + + diff --git a/app/static/portal/employee-operations/promotion/promotion.js b/app/static/portal/employee-operations/promotion/promotion.js new file mode 100644 index 0000000..d27f1c9 --- /dev/null +++ b/app/static/portal/employee-operations/promotion/promotion.js @@ -0,0 +1,241 @@ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { getAuthContext, requireOperator } from '/static/portal/common/auth.js'; +import { escapeHtml } from '/static/portal/common/formatters.js'; +import { mountShell } from '/static/portal/common/layout/app-shell.js'; +import { showToast } from '/static/portal/common/notifications.js'; + +const ATTACHMENT_LABELS = { manager_photo: '基金经理照片', performance_data: '业绩数据文件', source_evidence: '资料来源附件', template_file: '固定模板文件' }; +const FUND_RISK_LEVELS = { + 货币型: 'R1', + 债券型: 'R2', + 混合型: 'R3', + 股票型: 'R4', + 期货型: 'R5', +}; +const MANAGER_PROFILE_DEFAULT = '拥有多年证券与衍生品投资研究经验,曾先后任职于国内头部期货公司资产管理部、公募基金量化与衍生品投资部,历任研究员、投资经理、基金经理。对商品期货、股指期货、国债期货及多资产组合管理有深入实战积累,擅长在严格风险预算下运用衍生品工具进行方向性配置与对冲。'; +const DEFAULT_PROMOTION_INPUTS = { + product_info: { + fund_type: '混合型', + operation_mode: '开放式', + product_status: '募集期', + investment_objective: '通过专业资产配置与基本面研究,追求长期稳健的资产增值。', + benchmark: '', + risk_level: '', + }, + manager_info: { + manager_name: '待补充基金经理', + management_company: '南方基金管理有限公司', + registration_code: 'PT0100000002', + employment_years: '', + investment_management_experience: '', + profile: MANAGER_PROFILE_DEFAULT, + }, + team_info: { + team_description: '由投资、研究和风险管理人员组成完整投研团队,执行独立决策、协同研究和持续风险管理。', + research_capability: '覆盖宏观、行业、个券和风险管理等研究维度。', + }, + strategy_info: { + investment_scope: '股票、债券、货币市场工具及法律法规允许的其他资产。', + strategy: '通过大类资产配置、基本面研究和风险预算动态调整组合。', + restrictions: '遵守法律法规、基金合同及监管限制。', + index_tool_attribute: '支持指数增强与风险预算分析。', + }, + fee_structure: { + subscription_fee: '0.5%', + purchase_fee: '0.5%', + redemption_fee: '1.5%', + sales_service_fee: '1%', + management_fee: '1%', + custody_fee: '0.5%', + client_maintenance_fee: '不适用', + }, + performance_info: { + as_of_date: '', + history_months: 24, + product_return: '', + max_drawdown: '-3.6%', + volatility: '12%', + sharpe_ratio: '0.8', + show_product_performance: false, + show_manager_performance: false, + ranking: { enabled: false }, + }, + risk_disclosure: { special_risks: ['本基金可能面临市场风险、利率风险、汇率风险、政策风险、流动性风险及衍生品杠杆风险等,不保证本金安全,不保证最低收益,可能因市场波动而遭受本金损失。'], additional_notes: '' }, +}; + +if (requireOperator()) { + mountShell({ active: 'operator-promotion', mode: 'operator' }); + const context = getAuthContext(); + const state = { taskNo: '', task: null, generated: null, checks: [], files: {}, reviewDecision: 'approved', reviewComment: '', advisorIds: '' }; + const result = document.querySelector('[data-promotion-result]'); + document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员'; + document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`; + + function field(name) { return document.querySelector(`[data-promo-field="${name}"]`); } + function createField(name) { return document.querySelector(`[data-create-field="${name}"]`); } + function selectedFormats() { return [...document.querySelectorAll('[data-format]:checked')].map((item) => item.dataset.format); } + function text(name) { return String(field(name)?.value || '').trim(); } + function nullable(name) { return text(name) || null; } + function setFieldValue(name, value) { + const target = field(name); + if (!target || target.value || value === undefined || value === null || value === '') return; + target.value = String(value); + } + function applyDefaultInputs() { + Object.entries(DEFAULT_PROMOTION_INPUTS).forEach(([group, values]) => { + Object.entries(values).forEach(([key, value]) => { + if (key === 'ranking') { + if (value?.enabled) field(`${group}.ranking.enabled`).checked = true; + return; + } + if (Array.isArray(value)) { + setFieldValue(`${group}.${key}`, value.join('\n')); + return; + } + setFieldValue(`${group}.${key}`, value); + }); + }); + syncRiskLevel(); + } + function syncRiskLevel() { + const fundType = text('product_info.fund_type'); + const riskLevel = field('product_info.risk_level'); + if (riskLevel) riskLevel.value = FUND_RISK_LEVELS[fundType] || ''; + } + function syncTaskDefaults() { + const productName = createField('productName'); + const materialTitle = createField('materialTitle'); + if (productName && !productName.value) productName.value = '南方稳健配置产品'; + if (materialTitle && !materialTitle.value && productName?.value) { + materialTitle.value = `${productName.value}推介材料`; + } + } + function inputsPayload() { + return { + product_info: { fund_type: text('product_info.fund_type'), operation_mode: text('product_info.operation_mode'), product_status: text('product_info.product_status') || '募集期', investment_objective: text('product_info.investment_objective'), benchmark: nullable('product_info.benchmark'), risk_level: nullable('product_info.risk_level') }, + manager_info: { manager_name: text('manager_info.manager_name'), management_company: text('manager_info.management_company'), registration_code: text('manager_info.registration_code'), employment_years: nullable('manager_info.employment_years'), investment_management_experience: nullable('manager_info.investment_management_experience'), profile: nullable('manager_info.profile') }, + team_info: { team_description: text('team_info.team_description'), research_capability: nullable('team_info.research_capability') }, + strategy_info: { investment_scope: text('strategy_info.investment_scope'), strategy: text('strategy_info.strategy'), restrictions: text('strategy_info.restrictions'), index_tool_attribute: nullable('strategy_info.index_tool_attribute') }, + fee_structure: { subscription_fee: nullable('fee_structure.subscription_fee'), purchase_fee: nullable('fee_structure.purchase_fee'), redemption_fee: nullable('fee_structure.redemption_fee'), sales_service_fee: nullable('fee_structure.sales_service_fee'), management_fee: nullable('fee_structure.management_fee'), custody_fee: nullable('fee_structure.custody_fee'), client_maintenance_fee: nullable('fee_structure.client_maintenance_fee') }, + performance_info: { as_of_date: nullable('performance_info.as_of_date'), history_months: Number(field('performance_info.history_months')?.value || 0) || null, product_return: nullable('performance_info.product_return'), max_drawdown: nullable('performance_info.max_drawdown'), volatility: nullable('performance_info.volatility'), sharpe_ratio: nullable('performance_info.sharpe_ratio'), show_product_performance: Boolean(field('performance_info.show_product_performance')?.checked), show_manager_performance: Boolean(field('performance_info.show_manager_performance')?.checked), ranking: { enabled: Boolean(field('performance_info.ranking.enabled')?.checked) } }, + risk_disclosure: { special_risks: text('risk_disclosure.special_risks').split('\n').map((item) => item.trim()).filter(Boolean), additional_notes: nullable('risk_disclosure.additional_notes') }, + source_notes: {}, + }; + } + function taskPayload() { + return { product_name: String(createField('productName')?.value || '').trim(), product_code: String(createField('productCode')?.value || '').trim() || null, material_title: String(createField('materialTitle')?.value || '').trim(), style_code: createField('styleCode')?.value || 'balanced_allocation', output_formats: selectedFormats() }; + } + function setTaskStatus(status) { document.querySelector('[data-task-status]').textContent = state.taskNo ? `${state.taskNo} · ${status || '已连接'}` : '未创建任务'; } + function resultFile(label, path) { return `
${label}${escapeHtml(path || '未生成')}
`; } + function renderResult() { + const generated = state.generated; + const findings = state.checks || []; + result.innerHTML = `${generated ? `
材料生成结果已返回
${resultFile('PPTX', generated.pptx_path)}${resultFile('宣传长图', generated.poster_path)}${resultFile('PDF', generated.pdf_path)}${(generated.chart_paths || []).map((item, index) => resultFile(`图表 ${index + 1}`, item)).join('')}
${state.reviewDecision === 'approved' ? `
` : ''}` : ''}

合规检查结果

${findings.length} 条
${findings.length ? findings.map((item) => `
${escapeHtml(item.rule_name || item.rule_code || '合规规则')}
${escapeHtml(item.suggestion || item.hit_text || '已返回检查结果')} · ${escapeHtml(item.severity || '--')}
`).join('') : ''}
`; + const reviewDecision = result.querySelector('[data-review-decision]'); + if (reviewDecision) reviewDecision.value = state.reviewDecision; + autosizeTextareas(result); + } + function autosizeTextarea(target) { + target.style.height = 'auto'; + target.style.height = `${Math.max(target.scrollHeight, 72)}px`; + } + function autosizeTextareas(root = document) { + root.querySelectorAll('textarea').forEach(autosizeTextarea); + } + function validateAttachment(type, file) { + const extensions = { + manager_photo: ['.jpg', '.jpeg', '.png', '.webp'], + performance_data: ['.csv', '.xlsx', '.xlsm'], + source_evidence: ['.pdf', '.docx', '.xlsx', '.csv'], + template_file: ['.pptx'], + }; + const filename = String(file.name || '').toLowerCase(); + const extension = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : ''; + if (!extensions[type]?.includes(extension)) { + throw new Error(`${ATTACHMENT_LABELS[type]}仅支持:${extensions[type].join('、')}`); + } + const maxSize = type === 'manager_photo' ? 10 * 1024 * 1024 : type === 'performance_data' ? 20 * 1024 * 1024 : type === 'source_evidence' ? 30 * 1024 * 1024 : 50 * 1024 * 1024; + if (file.size > maxSize) throw new Error(`${ATTACHMENT_LABELS[type]}不能超过 ${Math.round(maxSize / 1024 / 1024)} MB`); + } + async function run(action) { + try { + if (action === 'create-task') { + const formats = selectedFormats(); + if (!formats.length) throw new Error('至少选择一种输出格式'); + const response = await apiClient.post('PROMOTION_CREATE', taskPayload()); + state.taskNo = response.data.task_no; state.task = response.data; setTaskStatus(response.data.status); showToast(`材料任务已创建:${state.taskNo}`); return; + } + if (action === 'load-task') { + state.taskNo = String(createField('taskNo')?.value || '').trim(); + if (!state.taskNo) throw new Error('请输入任务编号'); + const response = await apiClient.get('PROMOTION_TASK', { pathParams: { taskNo: state.taskNo } }); + state.task = response.data; const version = response.data.material_version; + state.generated = version ? { material_version_id: version.id, status: version.status, pptx_path: version.pptx_path, pdf_path: version.pdf_path, poster_path: version.poster_path, chart_paths: version.chart_paths || [] } : null; + setTaskStatus(response.data.status); renderResult(); showToast('材料任务已恢复'); return; + } + if (!state.taskNo) throw new Error('请先创建或读取材料任务'); + if (action === 'save-inputs') { await apiClient.post('PROMOTION_INPUTS', inputsPayload(), { pathParams: { taskNo: state.taskNo } }); setTaskStatus('input_ready'); showToast('结构化资料已保存'); return; } + if (action === 'upload') { + const selected = Object.entries(state.files).filter(([, file]) => file); + if (!selected.length) throw new Error('请先选择要上传的附件'); + const uploaded = []; + const failed = []; + for (const [type, file] of selected) { + try { + validateAttachment(type, file); + const form = new FormData(); + form.append('file', file, file.name); + await apiClient.upload('PROMOTION_ATTACHMENT', form, { + pathParams: { taskNo: state.taskNo }, + query: { attachment_type: type }, + idempotencyKey: `${state.taskNo}-${type}-${file.name}-${file.size}`, + }); + uploaded.push(ATTACHMENT_LABELS[type]); + } catch (error) { + failed.push(`${ATTACHMENT_LABELS[type]}上传失败:${error.message}`); + } + } + if (failed.length) throw new Error(`${uploaded.length ? `已上传 ${uploaded.join('、')};` : ''}${failed.join(';')}`); + showToast(`已上传 ${uploaded.join('、')}`); + return; + } + if (action === 'generate') { + try { + const response = await apiClient.post('PROMOTION_GENERATE', { output_formats: selectedFormats() }, { pathParams: { taskNo: state.taskNo } }); + state.generated = response.data; state.checks = response.data.findings || state.checks; setTaskStatus(response.data.status); renderResult(); showToast('材料生成结果已返回'); + } catch (error) { + const findings = error.payload?.data?.findings; + if (Array.isArray(findings) && findings.length) { state.checks = findings; renderResult(); } + throw error; + } + return; + } + if (action === 'checks') { const response = await apiClient.get('PROMOTION_CHECKS', { pathParams: { taskNo: state.taskNo } }); state.checks = response.data.findings || []; renderResult(); showToast(`已读取 ${state.checks.length} 条合规结果`); return; } + if (action === 'review') { const version = state.generated?.material_version_id; if (!version) throw new Error('未找到材料版本号'); const response = await apiClient.post('PROMOTION_REVIEW', { material_version_id: Number(version), decision: state.reviewDecision, comment: state.reviewComment || null }, { pathParams: { taskNo: state.taskNo } }); state.task = response.data; setTaskStatus(response.data.status); renderResult(); showToast('审核结果已提交'); return; } + if (action === 'deliver') { const version = state.generated?.material_version_id; const advisorIds = state.advisorIds.split(',').map((item) => Number(item.trim())).filter((item) => Number.isInteger(item) && item > 0); if (!version || !advisorIds.length) throw new Error('请填写至少一个有效投顾编号'); const response = await apiClient.post('PROMOTION_DELIVERY', { material_version_id: Number(version), advisor_ids: advisorIds, delivery_channel: 'internal_record' }, { pathParams: { taskNo: state.taskNo } }); state.task = response.data; setTaskStatus(response.data.status); showToast('材料已提交投顾交付'); } + } catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); } + } + document.querySelector('[data-action="create-task"]').addEventListener('click', () => run('create-task')); + document.querySelector('[data-action="load-task"]').addEventListener('click', () => run('load-task')); + document.querySelector('[data-create-field="productName"]').addEventListener('input', () => { + const productName = createField('productName'); + const materialTitle = createField('materialTitle'); + if (materialTitle && !materialTitle.dataset.userEdited) { + materialTitle.value = productName.value ? `${productName.value}推介材料` : ''; + } + }); + document.querySelector('[data-create-field="materialTitle"]').addEventListener('input', (event) => { + event.target.dataset.userEdited = 'true'; + }); + field('product_info.fund_type').addEventListener('change', syncRiskLevel); + document.querySelectorAll('[data-file]').forEach((input) => input.addEventListener('change', () => { state.files[input.dataset.file] = input.files[0] || null; })); + document.querySelector('[data-promotion-result]').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action) run(action); }); + document.querySelector('main').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action && ['save-inputs', 'upload', 'generate', 'checks'].includes(action)) run(action); }); + document.querySelector('[data-promotion-result]').addEventListener('input', (event) => { if (event.target.matches('[data-review-comment]')) state.reviewComment = event.target.value; if (event.target.matches('[data-advisor-ids]')) state.advisorIds = event.target.value; }); + document.querySelector('[data-promotion-result]').addEventListener('change', (event) => { if (event.target.matches('[data-review-decision]')) { state.reviewDecision = event.target.value; renderResult(); } }); + applyDefaultInputs(); + syncTaskDefaults(); + document.querySelectorAll('textarea').forEach((textarea) => textarea.addEventListener('input', () => autosizeTextarea(textarea))); + autosizeTextareas(); + renderResult(); +} diff --git a/tests/integration/test_offsite_fund_api.py b/tests/integration/test_offsite_fund_api.py index 3cdccc2..e9650c9 100644 --- a/tests/integration/test_offsite_fund_api.py +++ b/tests/integration/test_offsite_fund_api.py @@ -139,10 +139,11 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: recalc = client.post( "/api/v1/offsite-fund/settlement-statistics/recalculate", - json={"fund_code": "000001", "application_date": "2026-09-10"}, + json={"application_date": "2026-09-10"}, ) assert recalc.status_code == 200 - assert recalc.json()["data"]["subscription_amount_yuan"] == "0" + assert recalc.json()["data"]["fund_count"] == 1 + assert recalc.json()["data"]["items"][0]["subscription_amount_yuan"] == "0" notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", diff --git a/tests/integration/test_promotion_material_api.py b/tests/integration/test_promotion_material_api.py index d6a7dc0..b38e330 100644 --- a/tests/integration/test_promotion_material_api.py +++ b/tests/integration/test_promotion_material_api.py @@ -126,7 +126,8 @@ def test_promotion_material_http_workflow_and_advisor_scope(tmp_path: Path) -> N "file": ( "manager-photo.jpg", _manager_photo_bytes(), - "image/jpeg", + # 模拟部分 Windows 浏览器/代理上传图片时给出的通用媒体类型。 + "application/octet-stream", ) }, ) diff --git a/tests/unit/service/test_offsite_document_recognition_adapter.py b/tests/unit/service/test_offsite_document_recognition_adapter.py index 66f85b9..32172c8 100644 --- a/tests/unit/service/test_offsite_document_recognition_adapter.py +++ b/tests/unit/service/test_offsite_document_recognition_adapter.py @@ -36,6 +36,22 @@ async def test_default_recognition_uses_mock_without_external_call() -> None: assert result.missing_fields == () +@pytest.mark.asyncio +async def test_mock_recognition_separates_currency_from_subscription_amount() -> None: + source = RecognitionSourceFile( + filename="申购申请单.txt", + media_type="text/plain", + file_hash="hash-currency-mock", + payload="申购金额:人民币5000元 金额单位:元".encode(), + ) + + result = await OffsiteDocumentRecognitionAdapter(_settings()).recognize(source) + + assert result.extracted_fields["申购金额"] == "5000" + assert result.extracted_fields["币种"] == "人民币" + assert result.extracted_fields["金额单位"] == "元" + + @pytest.mark.asyncio async def test_docx_text_fallback_maps_product_code_to_fund_code() -> None: document_xml = """ @@ -99,7 +115,7 @@ async def test_enabled_recognition_calls_ocr_and_deepseek_with_masked_request_bo 200, json={ "data": { - "ocr_text": "基金代码:000001 申请编号:SUB-001 申购金额:10000", + "ocr_text": "基金代码:000001 申请编号:SUB-001 申购金额:人民币10,000元", "tables": [{"rows": 1}], "page_evidence": {"基金代码": [{"page": 1}]}, } @@ -123,7 +139,8 @@ async def test_enabled_recognition_calls_ocr_and_deepseek_with_masked_request_bo "申请编号": "SUB-001", "申请日期": "2026-09-10", "代销机构": "测试代销", - "申购金额": "10000", + "申购金额": "人民币10,000元", + "币种": "人民币", "金额单位": "元", }, "field_confidence": {"基金代码": "0.99"}, @@ -170,6 +187,9 @@ async def test_enabled_recognition_calls_ocr_and_deepseek_with_masked_request_bo assert result.ocr_status == "success" assert result.llm_status == "success" assert result.extracted_fields["基金代码"] == "000001" + assert result.extracted_fields["申购金额"] == "10000" + assert result.extracted_fields["币种"] == "人民币" + assert result.extracted_fields["金额单位"] == "元" assert result.field_confidence["基金代码"].to_eng_string() == "0.99" assert result.missing_fields == () assert requests[0].headers["x-acs-accesskey-id"] == "aliyun-id" diff --git a/tests/unit/service/test_offsite_fund_rules.py b/tests/unit/service/test_offsite_fund_rules.py index 1162b42..89d9bc7 100644 --- a/tests/unit/service/test_offsite_fund_rules.py +++ b/tests/unit/service/test_offsite_fund_rules.py @@ -22,6 +22,9 @@ def test_subscription_minimum_amount_boundary() -> None: assert below[0].result == "异常" assert equal[0].result == "异常" assert above[0].result == "正常" + assert above[0].calculation["实际值"] == "1.01" + assert above[0].calculation["规则值"] == "> 1 元" + assert above[0].calculation["比较"] == "1.01 > 1 元" def test_subscription_ratio_and_share_limit_boundary() -> None: @@ -37,6 +40,10 @@ def test_subscription_ratio_and_share_limit_boundary() -> None: "subscription_holding_ratio"] == "异常" assert {item.rule_code: item.result for item in abnormal}[ "subscription_single_share_limit"] == "异常" + holding = {item.rule_code: item for item in normal}["subscription_holding_ratio"] + assert holding.calculation["实际值"] == "20%" + assert holding.calculation["规则值"] == "≤ 20%" + assert holding.calculation["比较"] == "20% ≤ 20%" def test_redemption_ratio_and_available_quantity_boundary() -> None: diff --git a/tools/grant_promotion_operator.py b/tools/grant_promotion_operator.py new file mode 100644 index 0000000..55c06ff --- /dev/null +++ b/tools/grant_promotion_operator.py @@ -0,0 +1,154 @@ +"""给指定运营账号授予推介材料生成权限。""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy import text + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.contracts import RequestContext # noqa: E402 +from app.infrastructure.db import SessionFactory, engine # noqa: E402 +from app.service.identity_service import IdentityService # noqa: E402 + +PROMOTION_ROLE_CODE = "promotion_operator" +PROMOTION_ROLE_NAME = "推广材料运营" +DEFAULT_USER_ID = 9006 +GRANTED_CODES = ("promotion:read", "promotion:write") + + +async def apply(user_id: int, *, dry_run: bool) -> int: + now = datetime.now(UTC).replace(tzinfo=None) + assigned_at = now - timedelta(seconds=5) + async with SessionFactory() as session, session.begin(): + user = ( + await session.execute( + text( + "SELECT id, username, status FROM sys_user WHERE id=:user_id" + ), + {"user_id": user_id}, + ) + ).mappings().first() + if user is None: + raise SystemExit(f"未找到用户 id={user_id}") + if user["status"] != "正常": + raise SystemExit(f"用户 id={user_id} 未启用,拒绝授权") + + role_id = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code=:code"), + {"code": PROMOTION_ROLE_CODE}, + ) + permission_rows = ( + await session.execute( + text( + "SELECT permission_code, id FROM sys_permission " + "WHERE permission_code IN ('promotion:read', 'promotion:write')" + ) + ) + ).all() + permission_ids = {str(code): int(permission_id) for code, permission_id in permission_rows} + missing = [code for code in GRANTED_CODES if code not in permission_ids] + if missing: + raise SystemExit(f"数据库缺少权限码,请先运行权限种子:{missing}") + + print(f"用户:id={user_id} username={user['username']}") + print( + f"角色:{PROMOTION_ROLE_CODE} " + f"{'已存在 id=' + str(role_id) if role_id else '将新建'}" + ) + print(f"授权范围:{', '.join(GRANTED_CODES)}") + if dry_run: + print("[dry-run] 未写入任何数据") + return 0 + + if role_id is None: + await session.execute( + text( + """ + INSERT INTO sys_role + (role_code, role_name, status, created_at, updated_at) + VALUES (:code, :name, 'active', :now, :now) + """ + ), + {"code": PROMOTION_ROLE_CODE, "name": PROMOTION_ROLE_NAME, "now": now}, + ) + role_id = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code=:code"), + {"code": PROMOTION_ROLE_CODE}, + ) + role_id = int(role_id) + + for code in GRANTED_CODES: + exists = await session.scalar( + text( + """ + SELECT COUNT(*) FROM sys_role_permission + WHERE role_id=:role_id AND permission_id=:permission_id + """ + ), + {"role_id": role_id, "permission_id": permission_ids[code]}, + ) + if not exists: + await session.execute( + text( + """ + INSERT INTO sys_role_permission (role_id, permission_id, created_at) + VALUES (:role_id, :permission_id, :now) + """ + ), + { + "role_id": role_id, + "permission_id": permission_ids[code], + "now": now, + }, + ) + + await session.execute( + text( + """ + INSERT INTO sys_user_role (user_id, role_id, assigned_at) + VALUES (:user_id, :role_id, :assigned_at) + ON DUPLICATE KEY UPDATE assigned_at=:assigned_at + """ + ), + {"user_id": user_id, "role_id": role_id, "assigned_at": assigned_at}, + ) + + return await verify(user_id) + + +async def verify(user_id: int) -> int: + context = await IdentityService().resolve( + RequestContext(user_id=str(user_id), trace_id="grant-promotion-operator") + ) + expected = set(GRANTED_CODES) + actual = set(context.permissions) + print(f"真实解析角色:{sorted(context.roles)}") + print(f"真实解析权限:{sorted(actual)}") + missing = expected - actual + if missing: + raise SystemExit(f"授权验证失败,缺少权限:{sorted(missing)}") + print("推介材料生成权限已生效:promotion:read、promotion:write") + return 0 + + +async def main() -> int: + parser = argparse.ArgumentParser(description="授予运营账号推介材料生成权限") + parser.add_argument("--user-id", type=int, default=DEFAULT_USER_ID) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + try: + return await apply(args.user_id, dry_run=args.dry_run) + finally: + await engine.dispose() + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))