每日计划:明确否决的提议自动撤下,保留原因和并发保护(台账 073)
一、明确失效后结束旧建议。瑞芯微14:20自然重核返回REJECT,仍留待确认会误导用户;复用EXPIRED并记录撤下原因,暂时不可用继续守成。 二、撤下与拒绝留痕同事务,原状态和JSON防并发。沿用既有拒绝和重问,不调整准入、仓位或交易时段,不恢复旧批准。 三、30项专项和44批隔离禁网测试通过,含事务回滚、重复收尾及页面守卫;Node交互替身通过。候选镜像和155部署判收另记。
This commit is contained in:
parent
0e8a845c21
commit
60de23e79a
|
|
@ -2006,3 +2006,11 @@ bafecb7 经 45 策略用例、44 批本地及候选镜像禁网回归通过,11
|
||||||
### 14:05部署补记
|
### 14:05部署补记
|
||||||
|
|
||||||
4f6b192候选镜像44批禁网测试通过。13:59四容器统一重建为6ee444e1c734dc454a8564c9e6ae8f05c5e0c4b25ba9282049f61234a1db10af,Python指纹63b3e303084e。前后15项参数、账本、提议、指令及成交记录一致,对账severity=OK、QMT在线、健康正常。浏览器只读加载正常无错误。自动审批拒绝代点采纳,确认框交互以离线替身验证,真实失败提示仍待自然用户操作。科士达13:39自然确认成功已判收。详细原因、证据与回退见docs/提议采纳失败排查_2026-10-16.md。
|
4f6b192候选镜像44批禁网测试通过。13:59四容器统一重建为6ee444e1c734dc454a8564c9e6ae8f05c5e0c4b25ba9282049f61234a1db10af,Python指纹63b3e303084e。前后15项参数、账本、提议、指令及成交记录一致,对账severity=OK、QMT在线、健康正常。浏览器只读加载正常无错误。自动审批拒绝代点采纳,确认框交互以离线替身验证,真实失败提示仍待自然用户操作。科士达13:39自然确认成功已判收。详细原因、证据与回退见docs/提议采纳失败排查_2026-10-16.md。
|
||||||
|
|
||||||
|
## 2026-10-16 14:40 提议明确否决后退出待确认
|
||||||
|
|
||||||
|
用户看到瑞芯微14:20明确REJECT后仍可重复点采纳,要求失效提议撤下。采纳重核现在将研判拒绝、策略放弃和可核验的资格失效置为EXPIRED,原JSON标记withdrawn。数量及原依据保留。不可用或暂时性规则不足继续等待。拒绝原因优先取实际rejected,在途扫描背景不混入。
|
||||||
|
|
||||||
|
状态和拒绝留痕同一事务,原状态加原JSON防并发。研判拒绝沿用既有解锁重问,策略放弃沿用当日去重。已有保存的明确失败由正常扫描收尾,不新建任务、不调用模型。原文记录时刻保留,旧缺失基线不补造。页面撤下后只留原因与关闭,旧页面重复请求也返回已撤下。
|
||||||
|
|
||||||
|
四个新增反例在旧实现失败,修复后30专项及44批本地隔离禁网回归通过,Node交互替身通过。部署与真机状态待本节后续补记。只部署155模拟仓,188不动。未改交易时段、准入参数、数量算法或研判结论。
|
||||||
|
|
|
||||||
|
|
@ -291,3 +291,44 @@ def apply_audited_repair(plan: dict, guards: list, *, rollback=False) -> dict:
|
||||||
dict(at=now,op='history_repair_summary',by='codex',result='OK',reason='历史修复事务完成',
|
dict(at=now,op='history_repair_summary',by='codex',result='OK',reason='历史修复事务完成',
|
||||||
params=canonical({k:v for k,v in plan.items() if k not in ('changes','allocations','daily')}),ref=run_id))
|
params=canonical({k:v for k,v in plan.items() if k not in ('changes','allocations','daily')}),ref=run_id))
|
||||||
return {'ok':True,'run_id':run_id,'changed_rows':len(plan['changes'])}
|
return {'ok':True,'run_id':run_id,'changed_rows':len(plan['changes'])}
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_rejected_proposal(old, hard):
|
||||||
|
"""提议生命周期专用事务。每条语句单表,原状态与 JSON 一起防并发。"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
check = hard['last_accept_check']
|
||||||
|
stage = check['stage']
|
||||||
|
if stage not in ('judge', 'system', 'qualification') or check['status'] != 'withdrawn':
|
||||||
|
raise ValueError('只允许已确认的否决收尾')
|
||||||
|
encode = lambda v: json.dumps(v, ensure_ascii=False, default=str)
|
||||||
|
rejected = (check.get('details') or {}).get('rejected') or []
|
||||||
|
# 历史失败没有保存当时行情时,留空。不得把原提议旧价当作新否决的基线。
|
||||||
|
evidence = next((r.get('hard_numbers') for r in rejected if r.get('by') == stage and r.get('hard_numbers')), {})
|
||||||
|
ledger_hard = dict(evidence, proposal_withdrawal={'proposal_id': old['proposal_id'],
|
||||||
|
'check_at': check['at'], 'reason': check['message']})
|
||||||
|
now = datetime.now()
|
||||||
|
at = datetime.fromisoformat(check['at'])
|
||||||
|
if at.tzinfo is not None:
|
||||||
|
at = at.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
|
_check_cooldown('proxy')
|
||||||
|
with get_engine('proxy').begin() as c:
|
||||||
|
def query(sql, params):
|
||||||
|
sql = map_tables(sql)
|
||||||
|
assert_single_table(sql)
|
||||||
|
return c.execute(text(sql), params)
|
||||||
|
n = query("UPDATE pms_proposal SET status='EXPIRED', decided_at=:at, hard_numbers_json=:hn "
|
||||||
|
"WHERE proposal_id=:pid AND status='WAIT_USER' AND hard_numbers_json=:old",
|
||||||
|
{'at':now, 'hn':encode(hard), 'pid':old['proposal_id'], 'old':old['hard_numbers_json']}).rowcount
|
||||||
|
if not n:
|
||||||
|
return 0
|
||||||
|
query('INSERT INTO pms_action_ledger (ts_code,decided_at,action,arbiter,verdict,price_at,'
|
||||||
|
'hard_numbers_json,failed_checks_json,reason,ref_id,outcome_scored) '
|
||||||
|
'VALUES (:code,:at,:action,:arbiter,:verdict,:price,:hard,:failed,:reason,:ref,0)',
|
||||||
|
{'code':old['ts_code'], 'at':at, 'action':old['action'],
|
||||||
|
'arbiter':stage if stage in ('judge','system') else 'rule',
|
||||||
|
'verdict':'REJECT' if stage in ('judge','system') else 'NOTE',
|
||||||
|
'price':evidence.get('price') or 0, 'hard':encode(ledger_hard),
|
||||||
|
'failed':encode(check.get('reasons') or []),
|
||||||
|
'reason':('提议已撤下:'+check['message'])[:500], 'ref':old['proposal_id']})
|
||||||
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,12 @@ def record_proposal_check(old, hard_numbers):
|
||||||
'old': old['hard_numbers_json']})
|
'old': old['hard_numbers_json']})
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_proposal(old, hard_numbers):
|
||||||
|
"""关闭明确失效的旧建议;数据库层负责状态与拒绝留痕同成同败。"""
|
||||||
|
from app.db.session import withdraw_rejected_proposal
|
||||||
|
return withdraw_rejected_proposal(old, hard_numbers)
|
||||||
|
|
||||||
|
|
||||||
def decide_proposal(proposal_id: str, status: str, expected_hard=None) -> int:
|
def decide_proposal(proposal_id: str, status: str, expected_hard=None) -> int:
|
||||||
return execute("UPDATE pms_proposal SET status = :st, decided_at = :ts "
|
return execute("UPDATE pms_proposal SET status = :st, decided_at = :ts "
|
||||||
"WHERE proposal_id = :pid AND status = 'WAIT_USER'" +
|
"WHERE proposal_id = :pid AND status = 'WAIT_USER'" +
|
||||||
|
|
|
||||||
|
|
@ -1169,6 +1169,7 @@ def candidates(*, held=(), black=(), route_by_verdict: bool = False, force=False
|
||||||
rank_by_quality=p["rank_by_quality"],
|
rank_by_quality=p["rank_by_quality"],
|
||||||
rank_by_upstream=p["rank_by_upstream"],
|
rank_by_upstream=p["rank_by_upstream"],
|
||||||
observe_if_fund_bull=p["observe_if_fund_bull"])
|
observe_if_fund_bull=p["observe_if_fund_bull"])
|
||||||
|
out['refresh_status'] = plan.get('refresh_status')
|
||||||
out["age_tdays"] = plan.get("age_tdays")
|
out["age_tdays"] = plan.get("age_tdays")
|
||||||
out["theme_cap"] = plan.get("theme_cap")
|
out["theme_cap"] = plan.get("theme_cap")
|
||||||
out["route_by_verdict"] = route
|
out["route_by_verdict"] = route
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,11 @@ def current_basis(hard, code, proposal_id=None):
|
||||||
current = next((r for r in selected['items'] if r['ts_code'] == code), None)
|
current = next((r for r in selected['items'] if r['ts_code'] == code), None)
|
||||||
if not (selected.get('decision') or {}).get('id'):
|
if not (selected.get('decision') or {}).get('id'):
|
||||||
return {'ok': False, 'error': '当前计划没有可核验版本'}
|
return {'ok': False, 'error': '当前计划没有可核验版本'}
|
||||||
|
state = selected.get('refresh_status') or {}
|
||||||
|
if code in (state.get('pending_codes') or []) or (selected.get('consumption') or {}).get('error'):
|
||||||
|
return {'ok': False, 'error': '当前计划修订或消费留痕尚未完成'}
|
||||||
if current is None:
|
if current is None:
|
||||||
return {'ok': False, 'error': '该票已不满足当前候选资格', 'material': True}
|
return {'ok': False, 'error': '该票已不满足当前候选资格', 'material': True, 'withdraw': True}
|
||||||
result = compare(hard.get('plan_basis'), current, code)
|
result = compare(hard.get('plan_basis'), current, code)
|
||||||
if result == 'unavailable':
|
if result == 'unavailable':
|
||||||
return {'ok': False, 'error': '当前计划修订或消费留痕尚未完成'}
|
return {'ok': False, 'error': '当前计划修订或消费留痕尚未完成'}
|
||||||
|
|
@ -34,10 +37,13 @@ def current_basis(hard, code, proposal_id=None):
|
||||||
|
|
||||||
|
|
||||||
def _failed_recheck(p, out):
|
def _failed_recheck(p, out):
|
||||||
"""保留失败原因供页面和操作日志读取;不更新原批准依据或改变提议状态。"""
|
"""明确否决关闭旧提议;暂时性失败保留。原批准依据始终不升级。"""
|
||||||
from app.repo import pms_repo
|
from app.repo import pms_repo
|
||||||
rows = [r for k in ('rejected', 'skipped') for r in out.get(k, [])
|
def relevant(rows):
|
||||||
if not r.get('ts_code') or r['ts_code'] == p['ts_code']]
|
return [r for r in rows if not r.get('informational') and
|
||||||
|
(not r.get('ts_code') or r['ts_code'] == p['ts_code'])]
|
||||||
|
rejected = relevant(out.get('rejected', []))
|
||||||
|
rows = rejected or relevant(out.get('skipped', []))
|
||||||
reasons = []
|
reasons = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
values = row.get('failed') or [row.get('why')]
|
values = row.get('failed') or [row.get('why')]
|
||||||
|
|
@ -49,16 +55,38 @@ def _failed_recheck(p, out):
|
||||||
reasons.append(text)
|
reasons.append(text)
|
||||||
stage = rows[0].get('by', 'scan') if rows else 'scan'
|
stage = rows[0].get('by', 'scan') if rows else 'scan'
|
||||||
title = {'rule': '交易条件不满足', 'judge': '最新研判不建议建仓',
|
title = {'rule': '交易条件不满足', 'judge': '最新研判不建议建仓',
|
||||||
'system': '当前策略不建议建仓'}.get(stage, '暂不满足建仓条件')
|
'system': '当前策略不建议建仓', 'qualification': '候选条件已变化'}.get(stage, '暂不满足建仓条件')
|
||||||
message = title + (':' + ';'.join(reasons) if reasons else ',请稍后查看更新的建议')
|
message = title + (':' + ';'.join(reasons) if reasons else ',请稍后查看更新的建议')
|
||||||
check = {'status': 'blocked', 'at': datetime.now().astimezone().isoformat(),
|
withdrawn = bool(rejected and stage in ('judge', 'system', 'qualification'))
|
||||||
|
# 已有检查收尾保留原发生时刻;不假造一次新的研判。
|
||||||
|
old_check = (p.get('hard_numbers') or {}).get('last_accept_check') or {}
|
||||||
|
at = old_check.get('at') if out is old_check.get('details') else None
|
||||||
|
check = {'status': 'withdrawn' if withdrawn else 'blocked',
|
||||||
|
'at': at or datetime.now().astimezone().isoformat(),
|
||||||
'stage': stage, 'message': message, 'reasons': reasons, 'details': out}
|
'stage': stage, 'message': message, 'reasons': reasons, 'details': out}
|
||||||
hard = dict(p.get('hard_numbers') or {}, last_accept_check=check)
|
hard = dict(p.get('hard_numbers') or {}, last_accept_check=check)
|
||||||
if not pms_repo.record_proposal_check(p, hard):
|
save = pms_repo.withdraw_proposal if withdrawn else pms_repo.record_proposal_check
|
||||||
|
if not save(p, hard):
|
||||||
return {'ok': False, 'error': '提议已被更新,请刷新后查看当前结果',
|
return {'ok': False, 'error': '提议已被更新,请刷新后查看当前结果',
|
||||||
'proposal_id': p['proposal_id']}
|
'proposal_id': p['proposal_id']}
|
||||||
return {'ok': False, 'error': message, 'details': out, 'proposal_id': p['proposal_id'],
|
return {'ok': False, 'withdrawn': withdrawn,
|
||||||
'proposal': dict(p, hard_numbers=hard)}
|
'error': ('提议已撤下。' if withdrawn else '') + message,
|
||||||
|
'details': out, 'proposal_id': p['proposal_id'],
|
||||||
|
'proposal': dict(p, hard_numbers=hard, status='EXPIRED' if withdrawn else p.get('status', 'WAIT_USER'))}
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_recorded_rejections():
|
||||||
|
"""旧失败记录由既有扫描收尾。只读结构化否决,不发研判、不按文字猜测。"""
|
||||||
|
from app.repo import pms_repo
|
||||||
|
count = 0
|
||||||
|
for p in pms_repo.list_proposals(statuses=('WAIT_USER',), limit=200):
|
||||||
|
hard = p.get('hard_numbers') or {}
|
||||||
|
check = hard.get('last_accept_check') or {}
|
||||||
|
if (p.get('action') == 'OPEN' and hard.get('kind') != 'strategy' and
|
||||||
|
check.get('status') == 'blocked' and check.get('stage') in ('judge', 'system') and
|
||||||
|
(check.get('details') or {}).get('rejected')):
|
||||||
|
count += bool(_failed_recheck(p, check['details']).get('withdrawn'))
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def before_accept(p, expected_revision):
|
def before_accept(p, expected_revision):
|
||||||
|
|
@ -76,6 +104,8 @@ def before_accept(p, expected_revision):
|
||||||
if hard.get('proposal_revision') and expected_revision != hard['proposal_revision']:
|
if hard.get('proposal_revision') and expected_revision != hard['proposal_revision']:
|
||||||
return {'ok': False, 'error': '提议内容已更新,请刷新后再次确认', 'proposal': p}
|
return {'ok': False, 'error': '提议内容已更新,请刷新后再次确认', 'proposal': p}
|
||||||
return {'ok': True, 'proposal': dict(p, hard_numbers=dict(hard, **check['provenance']))}
|
return {'ok': True, 'proposal': dict(p, hard_numbers=dict(hard, **check['provenance']))}
|
||||||
|
if check.get('withdraw'):
|
||||||
|
return _failed_recheck(p, {'rejected': [{'by': 'qualification', 'why': check['error']}]})
|
||||||
if not check.get('material') or not check.get('view'):
|
if not check.get('material') or not check.get('view'):
|
||||||
return check
|
return check
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
||||||
# 就清掉, 不能等 15:10 结算; 也免得下面的跳过集合把一条早该作废的提议当在途。只读试算不动表。
|
# 就清掉, 不能等 15:10 结算; 也免得下面的跳过集合把一条早该作废的提议当在途。只读试算不动表。
|
||||||
try:
|
try:
|
||||||
out["expired_proposals"] = pms_repo.expire_proposals(now)
|
out["expired_proposals"] = pms_repo.expire_proposals(now)
|
||||||
|
from app.services.plan_recheck import withdraw_recorded_rejections
|
||||||
|
out["withdrawn_proposals"] = withdraw_recorded_rejections()
|
||||||
except Exception as e: # noqa: BLE001 —— 清扫失败不挡扫描
|
except Exception as e: # noqa: BLE001 —— 清扫失败不挡扫描
|
||||||
out["errors"].append(f"过期提议清扫失败: {e}")
|
out["errors"].append(f"过期提议清扫失败: {e}")
|
||||||
|
|
||||||
|
|
@ -394,7 +396,7 @@ def _scan_open(view, params, stock_params, skip, mkt, out, now=None,
|
||||||
- float(t["portfolio_mv"] or 0) - inflight["amount"]
|
- float(t["portfolio_mv"] or 0) - inflight["amount"]
|
||||||
if inflight["names"]:
|
if inflight["names"]:
|
||||||
out["skipped"].append({"action": ae.A_OPEN,
|
out["skipped"].append({"action": ae.A_OPEN,
|
||||||
"why": f"在途建仓已占 {len(inflight['names'])} 个名额、"
|
"informational": True, "why": f"在途建仓已占 {len(inflight['names'])} 个名额、"
|
||||||
f"约 {inflight['amount']:,.0f} 元 "
|
f"约 {inflight['amount']:,.0f} 元 "
|
||||||
f"({sorted(inflight['names'])[:6]}), 本轮按余量扫描"})
|
f"({sorted(inflight['names'])[:6]}), 本轮按余量扫描"})
|
||||||
# 真实可用资金封顶。**这一条比规则闸严一档, 是刻意的**: 规则闸那条「拿不到 ws 资金快照
|
# 真实可用资金封顶。**这一条比规则闸严一档, 是刻意的**: 规则闸那条「拿不到 ws 资金快照
|
||||||
|
|
@ -876,6 +878,13 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
||||||
if verdict["verdict"] == judge.REJECT:
|
if verdict["verdict"] == judge.REJECT:
|
||||||
out["rejected"].append({"ts_code": code, "action": action, "by": "judge",
|
out["rejected"].append({"ts_code": code, "action": action, "by": "judge",
|
||||||
"failed": [verdict.get("reason") or "决策系统驳回"]})
|
"failed": [verdict.get("reason") or "决策系统驳回"]})
|
||||||
|
if review_only:
|
||||||
|
rh = dict(c.get("hard_numbers") or {}, judge_conf=verdict.get("confidence"))
|
||||||
|
try:
|
||||||
|
rh["reask_base"] = reask_service.base_snapshot(code, price, judge_conf=verdict.get("confidence"))
|
||||||
|
except Exception:
|
||||||
|
pass # 基线未知时守成,不用当前值补造历史。
|
||||||
|
out["rejected"][-1]["hard_numbers"] = rh
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# 驳回也把把握度记进硬数字 (2026-09-08): 低把握驳回转交人那道保险 (PMS_JUDGE_REJECT_CONF_MIN)
|
# 驳回也把把握度记进硬数字 (2026-09-08): 低把握驳回转交人那道保险 (PMS_JUDGE_REJECT_CONF_MIN)
|
||||||
# 是否在起作用, 只能从驳回行的把握度分布看出来; 此前只有提议行记它, 驳回行没有, 复核无据。
|
# 是否在起作用, 只能从驳回行的把握度分布看出来; 此前只有提议行记它, 驳回行没有, 复核无据。
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,10 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
|
||||||
if not p:
|
if not p:
|
||||||
return {"ok": False, "error": "提议不存在"}
|
return {"ok": False, "error": "提议不存在"}
|
||||||
if p["status"] != "WAIT_USER":
|
if p["status"] != "WAIT_USER":
|
||||||
|
check = (p.get('hard_numbers') or {}).get('last_accept_check') or {}
|
||||||
|
if p['status'] == 'EXPIRED' and check.get('status') == 'withdrawn':
|
||||||
|
return {'ok': False, 'withdrawn': True, 'proposal_id': proposal_id, 'proposal': p,
|
||||||
|
'error': '提议已撤下。' + (check.get('message') or '当前已不建议执行')}
|
||||||
return {"ok": False, "error": f"提议已处于 {p['status']}"}
|
return {"ok": False, "error": f"提议已处于 {p['status']}"}
|
||||||
# 过了失效时刻的不能再裁决 (2026-09-16): 状态还没被清扫成 EXPIRED 也一样, 顺手清一遍。
|
# 过了失效时刻的不能再裁决 (2026-09-16): 状态还没被清扫成 EXPIRED 也一样, 顺手清一遍。
|
||||||
from app.services import proposal_service as psvc
|
from app.services import proposal_service as psvc
|
||||||
|
|
|
||||||
|
|
@ -1412,8 +1412,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<!-- 裁决弹窗 (2026-09-14 台账 015): 采纳/驳回从预设理由里选, 选其他再补一句。 -->
|
<!-- 裁决弹窗 (2026-09-14 台账 015): 采纳/驳回从预设理由里选, 选其他再补一句。 -->
|
||||||
<el-dialog v-model="decideDlg.visible" width="460px"
|
<el-dialog v-model="decideDlg.visible" width="460px"
|
||||||
:close-on-click-modal="!decideDlg.busy" :close-on-press-escape="!decideDlg.busy" :show-close="!decideDlg.busy"
|
:close-on-click-modal="!decideDlg.busy" :close-on-press-escape="!decideDlg.busy" :show-close="!decideDlg.busy"
|
||||||
:title="(decideDlg.decision==='ACCEPTED'?'采纳':'驳回') + ' · ' + (decideDlg.row ? nm(decideDlg.row.ts_code) : '') + (decideDlg.row ? ' ' + tx('action', decideDlg.row.action) : '')">
|
:title="(decideDlg.withdrawn?'提议已撤下':(decideDlg.decision==='ACCEPTED'?'采纳':'驳回')) + ' · ' + (decideDlg.row ? nm(decideDlg.row.ts_code) : '') + (decideDlg.row ? ' ' + tx('action', decideDlg.row.action) : '')">
|
||||||
<div v-if="decideDlg.row" class="prop-order" style="margin-bottom:10px">
|
<div v-if="decideDlg.row && !decideDlg.withdrawn" class="prop-order" style="margin-bottom:10px">
|
||||||
<b>{{ decideDlg.row.qty }} 股</b><span>预计金额 <b>{{ propAmount(decideDlg.row) }}</b></span>
|
<b>{{ decideDlg.row.qty }} 股</b><span>预计金额 <b>{{ propAmount(decideDlg.row) }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
<el-alert v-if="decideDlg.message" :title="decideDlg.message" type="warning" :closable="false"
|
<el-alert v-if="decideDlg.message" :title="decideDlg.message" type="warning" :closable="false"
|
||||||
|
|
@ -1422,15 +1422,17 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<div v-if="decideDlg.busy" class="muted" role="status" style="margin-bottom:10px">
|
<div v-if="decideDlg.busy" class="muted" role="status" style="margin-bottom:10px">
|
||||||
正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。
|
正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="!decideDlg.withdrawn">
|
||||||
<div class="muted" style="margin-bottom:8px;font-size:12px">选一个理由记进评审账本。选「其他」时补充必填。</div>
|
<div class="muted" style="margin-bottom:8px;font-size:12px">选一个理由记进评审账本。选「其他」时补充必填。</div>
|
||||||
<el-radio-group v-model="decideDlg.code" style="display:flex;flex-direction:column;gap:6px;align-items:flex-start">
|
<el-radio-group v-model="decideDlg.code" style="display:flex;flex-direction:column;gap:6px;align-items:flex-start">
|
||||||
<el-radio v-for="(label,code) in decideCodes" :key="code" :label="code">{{ label }}</el-radio>
|
<el-radio v-for="(label,code) in decideCodes" :key="code" :label="code">{{ label }}</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<el-input v-model="decideDlg.note" type="textarea" :rows="2" style="margin-top:10px"
|
<el-input v-model="decideDlg.note" type="textarea" :rows="2" style="margin-top:10px"
|
||||||
:placeholder="decideDlg.code && decideDlg.code.endsWith('_OTHER') ? '补充说明(必填)' : '补充说明(选填)'"></el-input>
|
:placeholder="decideDlg.code && decideDlg.code.endsWith('_OTHER') ? '补充说明(必填)' : '补充说明(选填)'"></el-input>
|
||||||
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button :disabled="decideDlg.busy" @click="decideDlg.visible=false">关闭</el-button>
|
<el-button :disabled="decideDlg.busy" @click="decideDlg.visible=false">关闭</el-button>
|
||||||
<el-button :type="decideDlg.decision==='ACCEPTED'?'success':'danger'" :loading="decideDlg.busy" :disabled="decideDlg.uncertain" @click="submitDecide">
|
<el-button v-if="!decideDlg.withdrawn" :type="decideDlg.decision==='ACCEPTED'?'success':'danger'" :loading="decideDlg.busy" :disabled="decideDlg.uncertain" @click="submitDecide">
|
||||||
{{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? (((decideDlg.row||{}).hard_numbers||{}).last_accept_check ? '重新核验' : '确认采纳') : '确认驳回')) }}</el-button>
|
{{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? (((decideDlg.row||{}).hard_numbers||{}).last_accept_check ? '重新核验' : '确认采纳') : '确认驳回')) }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
@ -4386,19 +4388,19 @@ createApp({
|
||||||
decideReasons.decline = src.decline || DECIDE_REASONS_FALLBACK.decline;
|
decideReasons.decline = src.decline || DECIDE_REASONS_FALLBACK.decline;
|
||||||
if (Number(src.request_timeout_ms) > 0) decideTimeoutMs = Number(src.request_timeout_ms);
|
if (Number(src.request_timeout_ms) > 0) decideTimeoutMs = Number(src.request_timeout_ms);
|
||||||
}
|
}
|
||||||
const decideDlg = reactive({ visible:false, busy:false, row:null, decision:'', code:'', note:'', message:'', updated:false, uncertain:false });
|
const decideDlg = reactive({ visible:false, busy:false, row:null, decision:'', code:'', note:'', message:'', updated:false, uncertain:false, withdrawn:false });
|
||||||
// 采纳/驳回都从预设理由里选一条 (选「其他」再补一句), 不再手写整段 (三源合议自决后剩的都是真交人的)。
|
// 采纳/驳回都从预设理由里选一条 (选「其他」再补一句), 不再手写整段 (三源合议自决后剩的都是真交人的)。
|
||||||
function decide(row, decision) {
|
function decide(row, decision) {
|
||||||
if (decideDlg.busy) return;
|
if (decideDlg.busy) return;
|
||||||
if (!Object.keys(decideReasons.accept).length) loadDecideReasons();
|
if (!Object.keys(decideReasons.accept).length) loadDecideReasons();
|
||||||
Object.assign(decideDlg, { visible:true, busy:false, row, decision, code:'', note:'',
|
Object.assign(decideDlg, { visible:true, busy:false, row, decision, code:'', note:'',
|
||||||
message:((row.hard_numbers||{}).last_accept_check||{}).message || '', updated:false, uncertain:false });
|
message:((row.hard_numbers||{}).last_accept_check||{}).message || '', updated:false, uncertain:false, withdrawn:false });
|
||||||
}
|
}
|
||||||
const decideCodes = computed(() =>
|
const decideCodes = computed(() =>
|
||||||
decideDlg.decision === 'ACCEPTED' ? decideReasons.accept : decideReasons.decline);
|
decideDlg.decision === 'ACCEPTED' ? decideReasons.accept : decideReasons.decline);
|
||||||
async function submitDecide() {
|
async function submitDecide() {
|
||||||
const dd = decideDlg;
|
const dd = decideDlg;
|
||||||
if (dd.busy || dd.uncertain) return;
|
if (dd.busy || dd.uncertain || dd.withdrawn) return;
|
||||||
if (!dd.code && !dd.note.trim()) { ElementPlus.ElMessage.warning('选一个理由,或补一句说明'); return; }
|
if (!dd.code && !dd.note.trim()) { ElementPlus.ElMessage.warning('选一个理由,或补一句说明'); return; }
|
||||||
if (dd.code && dd.code.endsWith('_OTHER') && !dd.note.trim()) {
|
if (dd.code && dd.code.endsWith('_OTHER') && !dd.note.trim()) {
|
||||||
ElementPlus.ElMessage.warning('选「其他」时必须填补充说明'); return; }
|
ElementPlus.ElMessage.warning('选「其他」时必须填补充说明'); return; }
|
||||||
|
|
@ -4416,12 +4418,13 @@ createApp({
|
||||||
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
|
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
|
||||||
} else {
|
} else {
|
||||||
dd.uncertain = !!d.uncertain;
|
dd.uncertain = !!d.uncertain;
|
||||||
|
dd.withdrawn = !!d.withdrawn;
|
||||||
dd.message = dd.uncertain
|
dd.message = dd.uncertain
|
||||||
? '尚未收到处理结果。请关闭后刷新,先核对待确认提议和当日指令,勿立即重复提交。'
|
? '尚未收到处理结果。请关闭后刷新,先核对待确认提议和当日指令,勿立即重复提交。'
|
||||||
: (d.error || '本次未能采纳,请稍后再试');
|
: (d.error || '本次未能采纳,请稍后再试');
|
||||||
if (d.proposal) {
|
if (d.proposal) {
|
||||||
dd.row = { ...dd.row, ...d.proposal };
|
dd.row = { ...dd.row, ...d.proposal };
|
||||||
dd.updated = !((d.proposal.hard_numbers||{}).last_accept_check);
|
dd.updated = !dd.withdrawn && !((d.proposal.hard_numbers||{}).last_accept_check);
|
||||||
if (dd.updated) { dd.code = ''; dd.note = ''; }
|
if (dd.updated) { dd.code = ''; dd.note = ''; }
|
||||||
await loadProposals();
|
await loadProposals();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
包含 (例数按 2026-10-12 隔离回归实跑校正):
|
包含 (例数按 2026-10-12 隔离回归实跑校正):
|
||||||
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
|
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
|
||||||
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
|
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
|
||||||
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明 (24 例)
|
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明、明确否决撤下 (30 例)
|
||||||
含在途报价变化不重复确认、真实承诺变化仍重核。
|
含在途报价变化不重复确认、真实承诺变化仍重核。
|
||||||
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
||||||
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ class DailyPlanTest(unittest.TestCase):
|
||||||
with patch.object(rc, 'current_basis', return_value={'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}), \
|
with patch.object(rc, 'current_basis', return_value={'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}), \
|
||||||
patch.object(ps, '_scan_open', return_value=[c]), patch.object(ps, '_route_one', side_effect=reject), \
|
patch.object(ps, '_scan_open', return_value=[c]), patch.object(ps, '_route_one', side_effect=reject), \
|
||||||
patch.object(param_store, 'get_int', return_value=0), \
|
patch.object(param_store, 'get_int', return_value=0), \
|
||||||
patch.object(pms_repo, 'record_proposal_check', return_value=1) as record, \
|
patch.object(pms_repo, 'withdraw_proposal', return_value=1) as record, \
|
||||||
patch.object(pms_repo, 'revise_pending_proposal', side_effect=AssertionError('不得升级原批准依据')), \
|
patch.object(pms_repo, 'revise_pending_proposal', side_effect=AssertionError('不得升级原批准依据')), \
|
||||||
patch.object(pms_repo, 'insert_instruction', side_effect=AssertionError('不得生成指令')):
|
patch.object(pms_repo, 'insert_instruction', side_effect=AssertionError('不得生成指令')):
|
||||||
out = rc.before_accept(p, 'old')
|
out = rc.before_accept(p, 'old')
|
||||||
|
|
@ -178,10 +178,10 @@ class DailyPlanTest(unittest.TestCase):
|
||||||
examples = [({'rejected': [{'by': 'rule', 'failed': ['CASH: 可用资金不足']}]}, '交易条件不满足:可用资金不足'),
|
examples = [({'rejected': [{'by': 'rule', 'failed': ['CASH: 可用资金不足']}]}, '交易条件不满足:可用资金不足'),
|
||||||
({'rejected': [{'by': 'system', 'why': '逻辑存疑'}]}, '当前策略不建议建仓:逻辑存疑'),
|
({'rejected': [{'by': 'system', 'why': '逻辑存疑'}]}, '当前策略不建议建仓:逻辑存疑'),
|
||||||
({'skipped': [{'why': '没有可用名额'}]}, '暂不满足建仓条件:没有可用名额')]
|
({'skipped': [{'why': '没有可用名额'}]}, '暂不满足建仓条件:没有可用名额')]
|
||||||
with patch.object(pms_repo, 'record_proposal_check', return_value=1):
|
with patch.object(pms_repo, 'record_proposal_check', return_value=1), patch.object(pms_repo, 'withdraw_proposal', return_value=1):
|
||||||
for details, reason in examples:
|
for details, reason in examples:
|
||||||
out = rc._failed_recheck(p, details)
|
out = rc._failed_recheck(p, details)
|
||||||
self.assertEqual(out['error'], reason)
|
self.assertEqual(out['error'].removeprefix('提议已撤下。'), reason)
|
||||||
self.assertFalse(out['ok'])
|
self.assertFalse(out['ok'])
|
||||||
|
|
||||||
def test_failed_recheck_does_not_overwrite_concurrent_proposal(self):
|
def test_failed_recheck_does_not_overwrite_concurrent_proposal(self):
|
||||||
|
|
@ -218,6 +218,70 @@ class DailyPlanTest(unittest.TestCase):
|
||||||
scan.assert_called_once()
|
scan.assert_called_once()
|
||||||
blocked.assert_called_once()
|
blocked.assert_called_once()
|
||||||
|
|
||||||
|
def test_definitive_rejection_withdraws_and_ignores_scan_context(self):
|
||||||
|
from app.services import plan_recheck as rc
|
||||||
|
from app.repo import pms_repo
|
||||||
|
p = {'proposal_id':'p1','ts_code':'600001.SH','action':'OPEN','qty':100,'hard_numbers':{}}
|
||||||
|
out = {'rejected':[{'ts_code':p['ts_code'],'by':'judge','failed':['资金流出。']}],
|
||||||
|
'skipped':[{'why':"在途建仓已占3个名额 ['SH600002']"}]}
|
||||||
|
with patch.object(pms_repo, 'withdraw_proposal', return_value=1, create=True) as withdraw, \
|
||||||
|
patch.object(pms_repo, 'record_proposal_check', side_effect=AssertionError('明确拒绝必须撤下')):
|
||||||
|
result = rc._failed_recheck(p,out)
|
||||||
|
self.assertTrue(result['withdrawn'])
|
||||||
|
self.assertEqual(result['proposal']['status'],'EXPIRED')
|
||||||
|
self.assertNotIn('在途',result['error'])
|
||||||
|
self.assertEqual(withdraw.call_args.args[1]['last_accept_check']['status'],'withdrawn')
|
||||||
|
|
||||||
|
def test_withdrawal_transaction_is_atomic_and_idempotent(self):
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from app.db import session
|
||||||
|
from app.repo import pms_repo
|
||||||
|
engine=create_engine('sqlite:///:memory:')
|
||||||
|
self.addCleanup(engine.dispose)
|
||||||
|
with engine.begin() as db:
|
||||||
|
db.execute(text('CREATE TABLE pms_proposal(proposal_id TEXT, status TEXT, decided_at TEXT, qty INTEGER, hard_numbers_json TEXT)'))
|
||||||
|
db.execute(text("INSERT INTO pms_proposal VALUES ('p1','WAIT_USER',NULL,100,'old')"))
|
||||||
|
old={'proposal_id':'p1','ts_code':'600001.SH','action':'OPEN','hard_numbers_json':'old'}
|
||||||
|
hard={'last_accept_check':{'stage':'judge','at':'2026-10-16T14:20:00+08:00','message':'资金流出','status':'withdrawn'}}
|
||||||
|
with patch.object(session,'get_engine',return_value=engine), patch.object(session,'_check_cooldown'):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
pms_repo.withdraw_proposal(old,hard) # 账本不存在,状态必须一起回滚。
|
||||||
|
with engine.begin() as db:
|
||||||
|
self.assertEqual(db.execute(text('SELECT status FROM pms_proposal')).scalar(),'WAIT_USER')
|
||||||
|
db.execute(text('CREATE TABLE pms_action_ledger(ts_code TEXT, decided_at TEXT, action TEXT, arbiter TEXT, verdict TEXT, price_at REAL, hard_numbers_json TEXT, failed_checks_json TEXT, reason TEXT, ref_id TEXT, outcome_scored INTEGER)'))
|
||||||
|
self.assertEqual(pms_repo.withdraw_proposal(old,hard),1)
|
||||||
|
self.assertEqual(pms_repo.withdraw_proposal(old,hard),0)
|
||||||
|
self.assertEqual(pms_repo.decide_proposal('p1','ACCEPTED',expected_hard='old'),0)
|
||||||
|
with engine.begin() as db:
|
||||||
|
self.assertEqual(tuple(db.execute(text('SELECT status,qty FROM pms_proposal')).one()),('EXPIRED',100))
|
||||||
|
self.assertEqual(db.execute(text('SELECT COUNT(*) FROM pms_action_ledger')).scalar(),1)
|
||||||
|
|
||||||
|
def test_recorded_rejections_cleanup_is_scoped_and_model_free(self):
|
||||||
|
from app.services import plan_recheck as rc
|
||||||
|
from app.repo import pms_repo
|
||||||
|
def prop(i,action='OPEN',stage='judge'):
|
||||||
|
return {'proposal_id':i,'ts_code':'600001.SH','action':action,'status':'WAIT_USER',
|
||||||
|
'hard_numbers':{'last_accept_check':{'status':'blocked','stage':stage,
|
||||||
|
'details':{'rejected':[{'by':stage,'failed':['原拒绝']} ]}}}}
|
||||||
|
rows=[prop('yes'),prop('sell','EXIT'),prop('rule',stage='rule'),prop('data',stage='scan')]
|
||||||
|
with patch.object(pms_repo,'list_proposals',return_value=rows), \
|
||||||
|
patch.object(rc,'_failed_recheck',return_value={'withdrawn':True}) as fail:
|
||||||
|
self.assertEqual(rc.withdraw_recorded_rejections(),1)
|
||||||
|
self.assertEqual(fail.call_args.args[0]['proposal_id'],'yes')
|
||||||
|
|
||||||
|
def test_unavailable_and_scan_notes_do_not_withdraw(self):
|
||||||
|
from app.services import plan_recheck as rc
|
||||||
|
from app.repo import pms_repo
|
||||||
|
p={'proposal_id':'p1','ts_code':'600001.SH','hard_numbers':{}}
|
||||||
|
with patch.object(pms_repo,'record_proposal_check',return_value=1), \
|
||||||
|
patch.object(pms_repo,'withdraw_proposal',side_effect=AssertionError('暂时失败保留'),create=True):
|
||||||
|
for detail in ({'skipped':[{'why':'候选池取不到'}]},
|
||||||
|
{'rejected':[{'by':'rule','failed':['PRICE: 读不到行情']}]},
|
||||||
|
{'skipped':[{'informational':True,'why':'在途占用'}]}):
|
||||||
|
out=rc._failed_recheck(p,detail)
|
||||||
|
self.assertFalse(out.get('withdrawn'))
|
||||||
|
self.assertNotIn('在途占用',out['error'])
|
||||||
|
|
||||||
def test_browser_wait_covers_effective_judge_timeout(self):
|
def test_browser_wait_covers_effective_judge_timeout(self):
|
||||||
from app.web import main
|
from app.web import main
|
||||||
with patch.object(main.param_store, 'get_int', return_value=240):
|
with patch.object(main.param_store, 'get_int', return_value=240):
|
||||||
|
|
@ -225,6 +289,29 @@ class DailyPlanTest(unittest.TestCase):
|
||||||
self.assertGreater(out['request_timeout_ms'], 240000)
|
self.assertGreater(out['request_timeout_ms'], 240000)
|
||||||
self.assertTrue(out['accept'])
|
self.assertTrue(out['accept'])
|
||||||
|
|
||||||
|
def test_missing_candidate_during_pending_revision_is_not_withdrawn(self):
|
||||||
|
from app.services import plan_recheck as rc, proposal_service as ps, portfolio, command_service
|
||||||
|
from app.repo import pms_repo
|
||||||
|
with patch.object(portfolio,'positions_view',return_value={'held':[]}), \
|
||||||
|
patch.object(command_service,'effective_stock_params',return_value={}), \
|
||||||
|
patch.object(ps,'_scan_params',return_value={}), \
|
||||||
|
patch.object(pf,'candidates',return_value={'items':[],'decision':{'id':'v2'},
|
||||||
|
'refresh_status':{'pending_codes':['600001.SH']}}):
|
||||||
|
out=rc.current_basis({},'600001.SH')
|
||||||
|
self.assertFalse(out['ok'])
|
||||||
|
self.assertFalse(out.get('withdraw'))
|
||||||
|
|
||||||
|
def test_verified_qualification_loss_withdraws_without_judgement(self):
|
||||||
|
from app.services import plan_recheck as rc, proposal_service as ps
|
||||||
|
from app.repo import pms_repo
|
||||||
|
p={'proposal_id':'p1','ts_code':'600001.SH','action':'OPEN','hard_numbers':{}}
|
||||||
|
with patch.object(rc,'current_basis',return_value={'ok':False,'material':True,
|
||||||
|
'withdraw':True,'error':'已不满足候选资格'}), \
|
||||||
|
patch.object(pms_repo,'withdraw_proposal',return_value=1), \
|
||||||
|
patch.object(ps,'_route_one',side_effect=AssertionError('无需新研判')):
|
||||||
|
out=rc.before_accept(p,None)
|
||||||
|
self.assertTrue(out['withdrawn'])
|
||||||
|
|
||||||
def test_version_unavailable_and_qualification_loss_preserve_proposal(self):
|
def test_version_unavailable_and_qualification_loss_preserve_proposal(self):
|
||||||
from app.services import plan_recheck as rc
|
from app.services import plan_recheck as rc
|
||||||
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {}}
|
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {}}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ function make(response){
|
||||||
let done;t=make(()=>new Promise(r=>{done=r}));t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';const pending=t.submitDecide();await t.submitDecide();
|
let done;t=make(()=>new Promise(r=>{done=r}));t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';const pending=t.submitDecide();await t.submitDecide();
|
||||||
t.decide({...row,proposal_id:'p2'},'DECLINED');assert.equal(t.decideDlg.row.proposal_id,'p1');assert.equal(t.count(),1);done({ok:true,instruction_id:'i1'});await pending;assert(!t.decideDlg.visible);
|
t.decide({...row,proposal_id:'p2'},'DECLINED');assert.equal(t.decideDlg.row.proposal_id,'p1');assert.equal(t.count(),1);done({ok:true,instruction_id:'i1'});await pending;assert(!t.decideDlg.visible);
|
||||||
t=make(()=>{throw new Error('unexpected')});t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';await t.submitDecide();assert(!t.decideDlg.busy);assert(t.decideDlg.uncertain);
|
t=make(()=>{throw new Error('unexpected')});t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';await t.submitDecide();assert(!t.decideDlg.busy);assert(t.decideDlg.uncertain);
|
||||||
|
t=make({ok:false,withdrawn:true,error:'提议已撤下。资金流出',proposal:{...row,status:'EXPIRED'}});
|
||||||
|
t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';await t.submitDecide();await t.submitDecide();
|
||||||
|
assert(t.decideDlg.withdrawn);assert(t.decideDlg.visible);assert(!t.decideDlg.updated);assert.equal(t.count(),1);
|
||||||
|
assert(html.includes('<el-button v-if="!decideDlg.withdrawn"'));assert(html.includes('<template v-if="!decideDlg.withdrawn">'));
|
||||||
|
t.decide(row,'ACCEPTED');assert(!t.decideDlg.withdrawn);
|
||||||
const script=html.match(/<script>\s*([\s\S]*?)<\/script>/)[1];new vm.Script(script);
|
const script=html.match(/<script>\s*([\s\S]*?)<\/script>/)[1];new vm.Script(script);
|
||||||
console.log('PASS: rejection, reconfirmation, uncertain result, concurrent click, finally and script syntax');
|
console.log('PASS: rejection, reconfirmation, uncertain result, concurrent click, finally and script syntax');
|
||||||
})().catch(e=>{console.error(e);process.exit(1)});
|
})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue