## 一、根因:核对查不到不是"缺数据",是**账户口径没桥接**
实测(客户 10002 的单据):
```
生成的 SQL: JOIN fin_holding h ... WHERE h.trade_account = '10002' ← 单据上的账户标识
结果: 0 行 → 「申请前持有份额」= null → 规则判「无法判断」→ 单据状态 query_failed
```
而库里 `fin_holding.trade_account` 存的是**平台交易账号** `FSA{客户号:06d}`
(与 `fin_sim_account.account_no` 同值,见 `tools/seed_sim_account_demo.py:226`),
客户 10002 **一直持有 15911 共 1000 份**。所以页面上"单据核对与运营动作"是空的,
看起来像缺数据,实为**单据印的账户标识与平台账号是两个口径,中间少了一步换算**。
修法:新增 `OffsiteFundService._platform_account()`,在**单据字段落库的两个入口**
(`_create_document` 新建、`_apply_recognition_fields` 保存/重试)做换算,规则:
1. 已是库里的 `account_no` → 原样;
2. 纯数字且命中 `customer_id` → 用该客户的 `account_no`;
3. 其余原样返回并留 warning —— **失败关闭,不猜不造**(不凭空生成 `FSA099999`)。
一处修好,后端查询、NL2SQL 页面的自然语言、页面展示三处口径一致。
附件上的 OCR **原始值不受影响**(`extracted_fields` 仍是单据上印的 `10002`)。
端到端证据(同一张单据,只走服务层):
| 阶段 | document.account_identifier | 规则数据 | 单据状态 |
|---|---|---|---|
| 修复前 | `10002` | `{}` → 无法判断 | `query_failed` |
| 保存识别字段后 | **`FSA010002`** | — | `query_failed` |
| 触发核对后 | `FSA010002` | **申请前持有份额 1000.0000** → **正常** | **planned** |
## 二、前端:保存识别字段后自动重跑核对
`employee-operations/offsite/offsite.js`:原先 `save-ocr` 只保存 + 重载页面,
**不触发核对**,于是运营改完字段点保存,那两个区块要么停留在上一次核对的状态、
要么整块是空的,必须再手动点一次"重新核对并判定规则"——看起来像"保存没生效"。
现在:保存 == 运营已人工确认该单据内容,因此保存成功后**自动对该附件关联的单据**
执行「触发 NL2SQL → 拉取返回字段 → 重新判定规则」,并在提示里区分"已重新核对"
与"核对未全部成功"。
顺带把这段逻辑抽成 `recalculateDocument(taskId)`,与面板上的"重新核对并判定规则"
按钮**走同一条路径** —— 两处各写一份正是"保存后不刷新"这类不一致的来源。
## 三、运营(operator)角色权限
`tools/grant_operator_role.py` 的授权清单补齐:`promotion:write/read/review/deliver`
(`promotion_material_service.py` 的八处 `_require` 恰好只用这四个码,缺任一都会 403,
例如只给 write 会在查看详情 read 那一步被拒)+ `agent:run`。
已实际执行并**从身份侧验证**(`IdentityRepository.load_context`):
9005 / 9006 现在各 7 项权限,四个推介材料码齐全。
- NL2SQL 全库与它相关的权限码**只有 `financial:nl2sql:read`**(`offsite:nl2sql` 在
`sys_permission` 里并不存在,是 `offsite_fund_service` 里 any-of 校验的死值),
该码运营早已有,本次无需新增。
- 可持续性已核实:`sys_role_permission` / `sys_user_role` **没有外键**,
重跑 `seed_test_rbac.py`(DELETE 重建 9001-9099 号段权限)**不会**删掉运营的绑定;
且本工具按**权限码查 id**、不写死 id,天然抗号段变动。
## 四、10001 / 10002 的 15911 持仓
复核结论:**各 1000 份,且三处口径一致**(`fin_holding.market_value` = 数量 × 最新净值、
净值历史 120 条、`fin_product.current_nav` 与净值最新一条一致、账户可用资金正常)。
`fin_holding` 的唯一键是 `(customer_id, product_id)`,所以**不能**再插一行
`trade_account='10002'` 的"同一个持仓"—— 那会把持仓重复计数,是错的。
需要改数量就用 `python tools/seed_custom_holdings.py --quantity N`(默认就是这两个客户 + 15911)。
## 五、验证与回归
- 新增 `tests/unit/service/test_offsite_account_bridge.py`(5 条:账号原样 / 客户号换算 /
认不出原样返回 / 不凭空造账号 / 空值不查库);
- `pytest tests/unit/service -k offsite` → 34 passed;
- 场外集成测试 4 个文件 → 27 passed;
- `ruff` 干净;`mypy app` 仍只有组员新代码里那 3 个既有错(与本次无关);
- 前端 `node --check offsite.js` 通过。
86 lines
3.3 KiB
Python
86 lines
3.3 KiB
Python
"""「单据账户标识 → 平台交易账号」换算(`OffsiteFundService._platform_account`)的回归测试。
|
||
|
||
## 为什么必须守住这条缝
|
||
|
||
场外核对的 NL2SQL 查询计划按 `fin_holding.trade_account = <单据上的账户标识>` 过滤
|
||
(`nl2sql_yc.py:509-514`),而库里存的是平台交易账号 `FSA{客户号:06d}`。两者**不同值**时:
|
||
|
||
```
|
||
JOIN fin_holding h ... WHERE h.trade_account = '10002' → 0 行
|
||
→ 「申请前持有份额」= null → 规则判「无法判断」→ 单据状态 query_failed
|
||
```
|
||
|
||
2026-09-14 实测就是这样:客户 10002 **明明持有 15911 共 1000 份**,核对却报"查询无可用数据",
|
||
页面上"单据核对与运营动作"因此是空的 —— 看起来像"缺数据",实为**账户口径没桥接**。
|
||
本文件把换算规则钉死:命中就换算,认不出来就**原样返回(失败关闭,不猜不造)**。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.service.offsite_fund_service import OffsiteFundService
|
||
|
||
|
||
class _StubSession:
|
||
"""按调用顺序返回预置结果的 session 替身;记录被执行的查询,不发真实 SQL。"""
|
||
|
||
def __init__(self, results: list[Any]) -> None:
|
||
self._results = list(results)
|
||
self.queries: list[str] = []
|
||
|
||
async def scalar(self, statement: Any) -> Any:
|
||
self.queries.append(str(statement))
|
||
return self._results.pop(0) if self._results else None
|
||
|
||
|
||
def _service(results: list[Any]) -> tuple[OffsiteFundService, _StubSession]:
|
||
session = _StubSession(results)
|
||
return OffsiteFundService(session), session # type: ignore[arg-type]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_account_no_is_used_as_is() -> None:
|
||
"""已经是库里的交易账号:原样使用,且**只查一次**(不需要按客户号回退)。"""
|
||
service, session = _service(["FSA010002"])
|
||
|
||
assert await service._platform_account("FSA010002") == "FSA010002"
|
||
assert len(session.queries) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_numeric_customer_id_is_translated_to_trade_account() -> None:
|
||
"""单据上印的是客户号(演示单据就是 `10002`)⇒ 换算成该客户的交易账号。"""
|
||
service, session = _service([None, "FSA010002"])
|
||
|
||
assert await service._platform_account("10002") == "FSA010002"
|
||
assert len(session.queries) == 2 # 先按 account_no 找、再按 customer_id 找
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unknown_identifier_is_returned_unchanged() -> None:
|
||
"""认不出来就原样返回:宁可继续查不到(暴露给人工),也不构造"看起来对"的账号。"""
|
||
service, _session = _service([None, None])
|
||
|
||
assert await service._platform_account("SL-UNKNOWN-001") == "SL-UNKNOWN-001"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_numeric_identifier_without_account_is_not_invented() -> None:
|
||
"""数字但是个不存在的客户号 ⇒ 不得凭空造出 `FSA099999` 这类账号。"""
|
||
service, _session = _service([None, None])
|
||
|
||
assert await service._platform_account("99999") == "99999"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_blank_identifier_needs_no_query() -> None:
|
||
"""空值不查库。"""
|
||
service, session = _service([])
|
||
|
||
assert await service._platform_account(None) is None
|
||
assert await service._platform_account("") is None
|
||
assert session.queries == []
|