提议采纳:保留拦截原因与确认内容,完善等待及结果反馈(台账 072)

一、真实拒绝需要让用户看见原因
瑞芯微13:40研判拒绝只回通用错误,原提议仍展示旧结论。失败保留具体原因与检查记录,条件更新避免覆盖并发结果,原数量及批准依据保持。

二、更新内容必须重新确认
确认框展示新数量与理由并清空旧选择。等待期间阻止重复点击,超时或断网不自动重发。浏览器等待覆盖有效研判超时与取数;已有失败不能复用旧批准。

三、验证与边界
24个专项、44批本地隔离禁网回归及Node交互替身通过。未改准入、研判、仓位、执行规则与时段,部署仅限155模拟仓。候选镜像和线上验证另记,188不操作。
This commit is contained in:
baobao 2026-10-16 13:56:08 +08:00
parent 938fff2e0f
commit 4f6b1922f1
8 changed files with 217 additions and 24 deletions

View File

@ -1993,3 +1993,11 @@ bafecb7 经 45 策略用例、44 批本地及候选镜像禁网回归通过,11
## 2026-10-16 13:35 · 用户确认时延口径与自然提议重核
用户明确十二分钟按连续交易时段核验,午休材料开盘后及时消费即可。八份盘中样本耗时114.360—393.202秒;六份午休材料于13:00:03消费,两项本轮样本通过。13:27:30用户自然确认科士达,新增rechecked79、动作账本6783;研究依据与扫描参数相同,仓位上下文变化,重跑后仍WAIT_USER、600股并要求再次确认,没有生成指令。无变化直接通过仍待自然操作。13:28对账无差异,参数账务与审计保持。本轮仅文档与验收口径更新,无代码、参数、重启或交易写操作。原心跳14:15接续,见四仓交接13:35节及桥台账071。
## 2026-10-16 13:55 · 采纳失败原因与等待反馈修复
用户报告提议采纳长时间转圈,失败提示一闪而过。13:39科士达真实采纳成功;13:40瑞芯微实际研判拒绝,原接口丢弃具体原因。研判审计与只读缓存均确认拒绝原因,不能将它改成通过。
修改 plan_recheck、pms_repo、web/main 与静态页面。失败原因保存到现有提议JSON,条件更新防并发覆盖,原数量、批准依据与状态保留。错误原因进入现有操作日志,不新增会影响自主决策的拒绝账本。已有失败须重新核验,不能因组合回到旧值复用旧批准。确认框持续显示原因与更新内容,防重复点击;请求等待随有效研判超时配置,未知结果不自动重发。交易规则与参数保持。
本地24个专项和44批隔离禁网测试全部通过,含三页面守卫。Node前端替身验证失败保留、更新确认、结果不明、重复点击及异常释放。此时尚未部署;候选镜像与线上判收另追加。回退须反向提交加旧镜像重建四容器。155当前绑定挂载/app,不能仅回退镜像。

View File

@ -525,6 +525,14 @@ def revise_pending_proposal(old, candidate, verdict):
'pid': old['proposal_id'], 'old': old['hard_numbers_json']})
def record_proposal_check(old, hard_numbers):
"""仅追加最近一次采纳检查结果,原数量、研判与待确认状态保持。"""
return execute('UPDATE pms_proposal SET hard_numbers_json=:hn WHERE proposal_id=:pid '
"AND status='WAIT_USER' AND hard_numbers_json=:old",
{'hn': _dumps(hard_numbers), 'pid': old['proposal_id'],
'old': old['hard_numbers_json']})
def decide_proposal(proposal_id: str, status: str, expected_hard=None) -> int:
return execute("UPDATE pms_proposal SET status = :st, decided_at = :ts "
"WHERE proposal_id = :pid AND status = 'WAIT_USER'" +

View File

@ -1,5 +1,6 @@
"""采纳前核验;复用原扫描、合议、研判与规则闸,不解释自然语言。"""
from datetime import datetime
import re
from app.core.plan_revision import compare, fingerprint, portfolio_context
@ -32,6 +33,34 @@ def current_basis(hard, code, proposal_id=None):
'reason': '依据相同,名次变化不撤销'}}}
def _failed_recheck(p, out):
"""保留失败原因供页面和操作日志读取;不更新原批准依据或改变提议状态。"""
from app.repo import pms_repo
rows = [r for k in ('rejected', 'skipped') for r in out.get(k, [])
if not r.get('ts_code') or r['ts_code'] == p['ts_code']]
reasons = []
for row in rows:
values = row.get('failed') or [row.get('why')]
if isinstance(values, str):
values = [values]
for value in values:
text = re.sub(r'^[A-Z][A-Z0-9_]*:\s*', '', str(value or '')).strip()
if text and text not in reasons:
reasons.append(text)
stage = rows[0].get('by', 'scan') if rows else 'scan'
title = {'rule': '交易条件不满足', 'judge': '最新研判不建议建仓',
'system': '当前策略不建议建仓'}.get(stage, '暂不满足建仓条件')
message = title + (':' + ';'.join(reasons) if reasons else ',请稍后查看更新的建议')
check = {'status': 'blocked', 'at': datetime.now().astimezone().isoformat(),
'stage': stage, 'message': message, 'reasons': reasons, 'details': out}
hard = dict(p.get('hard_numbers') or {}, last_accept_check=check)
if not pms_repo.record_proposal_check(p, hard):
return {'ok': False, 'error': '提议已被更新,请刷新后查看当前结果',
'proposal_id': p['proposal_id']}
return {'ok': False, 'error': message, 'details': out, 'proposal_id': p['proposal_id'],
'proposal': dict(p, hard_numbers=hard)}
def before_accept(p, expected_revision):
from app.services import proposal_service as ps
from app.repo import pms_repo
@ -40,6 +69,9 @@ def before_accept(p, expected_revision):
return {'ok': True, 'proposal': p}
try:
check = current_basis(hard, p['ts_code'], proposal_id=p.get('proposal_id'))
# 已有失败结论不能因仓位后来恰好回到旧值而沿用旧批准。下一次仍完整核验。
if check.get('ok') and hard.get('last_accept_check'):
check.update(ok=False, material=True)
if check.get('ok'):
if hard.get('proposal_revision') and expected_revision != hard['proposal_revision']:
return {'ok': False, 'error': '提议内容已更新,请刷新后再次确认', 'proposal': p}
@ -54,7 +86,7 @@ def before_accept(p, expected_revision):
fresh = ps._scan_open(view, params, check['stock'], {}, mkt, out, now=now,
review_code=p['ts_code'], exclude_proposal=p['proposal_id'])
if not fresh:
return {'ok': False, 'error': '重新评估未产出可采纳提议', 'details': out}
return _failed_recheck(p, out)
c = fresh[0]
c['_plan_recheck'] = True
from app.services import param_store
@ -62,7 +94,7 @@ def before_accept(p, expected_revision):
brake = td.ymd() < param_store.get_int('PMS_BRAKE_UNTIL', 0)
result = ps._route_one(c, view, params, check['stock'], brake, now, True, out, review_only=True)
if not result:
return {'ok': False, 'error': '新版重新评估未通过', 'details': out}
return _failed_recheck(p, out)
hn = c['hard_numbers']
hn['version_check'] = {'status': 'reconfirmed_required', 'at': now.astimezone().isoformat(),
'reason': '依据变化,已重跑合议、研判、数量及规则闸'}
@ -73,6 +105,8 @@ def before_accept(p, expected_revision):
verdict='WARN', price_at=hn.get('price') or 0, hard_numbers=hn,
ref_id=p['proposal_id'], reason='新版依据变化,更新提议并要求重新确认')
return {'ok': False, 'error': '提议已按新版重新评估,请查看更新内容后再次确认',
'proposal': dict(p, qty=c['qty'], hard_numbers=hn)}
'proposal': dict(p, qty=c['qty'], hard_numbers=hn,
judge_verdict=result['verdict'].get('verdict'),
judge_reason=result['verdict'].get('reason'))}
except Exception as e:
return {'ok': False, 'error': f'版本核验暂不可用,保留提议:{type(e).__name__}'}

View File

@ -481,7 +481,9 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
def api_decide_reasons():
"""裁决预设理由的两张码表 (采纳 / 驳回), 页面据此铺按钮。台账 015。"""
return {"ok": True, "accept": decide_reasons.ACCEPT_REASONS,
"decline": decide_reasons.DECLINE_REASONS}
"decline": decide_reasons.DECLINE_REASONS,
# 浏览器等待要覆盖研判超时与取数,不能先报失败、后端还在处理采纳。
"request_timeout_ms": max(120, param_store.get_int('PMS_JUDGE_TIMEOUT', 90) + 60) * 1000}
@app.post("/api/proposals/{proposal_id}/decide")

View File

@ -1273,6 +1273,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="sub">{{ propTimingSub(p) }}</span><i class="car">›</i>
</div>
</div>
<div v-if="(p.hard_numbers||{}).last_accept_check" class="st" style="color:var(--warn)">
最近核验未通过。<button class="research-link" @click="decide(p,'ACCEPTED')">查看原因</button>
</div>
<div class="prop-actions">
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
<el-button type="danger" size="small" @click="decide(p,'DECLINED')">驳回</el-button>
@ -1408,7 +1411,17 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- 挂交易方案 对话框 (非盲填: 现价 / 持仓 / 可卖 / 浮盈都摆出来) -->
<!-- 裁决弹窗 (2026-09-14 台账 015): 采纳/驳回从预设理由里选, 选其他再补一句。 -->
<el-dialog v-model="decideDlg.visible" width="460px"
: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) : '')">
<div v-if="decideDlg.row" class="prop-order" style="margin-bottom:10px">
<b>{{ decideDlg.row.qty }} 股</b><span>预计金额 <b>{{ propAmount(decideDlg.row) }}</b></span>
</div>
<el-alert v-if="decideDlg.message" :title="decideDlg.message" type="warning" :closable="false"
show-icon style="margin-bottom:10px;white-space:pre-line"></el-alert>
<div v-if="decideDlg.updated" class="why" style="margin-bottom:10px">{{ decideDlg.row.judge_reason || propWhy(decideDlg.row) }}</div>
<div v-if="decideDlg.busy" class="muted" role="status" style="margin-bottom:10px">
正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。
</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 v-for="(label,code) in decideCodes" :key="code" :label="code">{{ label }}</el-radio>
@ -1416,9 +1429,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-input v-model="decideDlg.note" type="textarea" :rows="2" style="margin-top:10px"
:placeholder="decideDlg.code && decideDlg.code.endsWith('_OTHER') ? '补充说明(必填)' : '补充说明(选填)'"></el-input>
<template #footer>
<el-button @click="decideDlg.visible=false">取消</el-button>
<el-button :type="decideDlg.decision==='ACCEPTED'?'success':'danger'" :loading="decideDlg.busy" @click="submitDecide">
{{ decideDlg.decision==='ACCEPTED'?'确认采纳':'确认驳回' }}</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">
{{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? (((decideDlg.row||{}).hard_numbers||{}).last_accept_check ? '重新核验' : '确认采纳') : '确认驳回')) }}</el-button>
</template>
</el-dialog>
@ -3704,9 +3717,9 @@ createApp({
// ===================== /交易员视图 =====================
async function call(method, url, body) {
async function call(method, url, body, options) {
try {
const r = await api[method](url, body);
const r = await api[method](url, body, options);
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
return r.data;
} catch (e) {
@ -3716,7 +3729,7 @@ createApp({
const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要系统管理员';
ElementPlus.ElMessage.error(m); return { ok: false, error: m };
}
err.value = url + ' → ' + e.message; return { ok: false, error: e.message };
err.value = url + ' → ' + e.message; return { ok: false, error: e.message, uncertain: !e.response || st >= 500 };
}
}
@ -4365,38 +4378,60 @@ createApp({
DECLINE_WAIT_PULLBACK:'等待回调再议', DECLINE_MARKET_RISK:'大盘风险偏高', DECLINE_OTHER:'其他' },
};
const decideReasons = reactive({ accept:{}, decline:{} });
let decideTimeoutMs = 180000;
async function loadDecideReasons() {
const d = await call('get', '/api/proposals/decide-reasons');
const src = (d && d.ok !== false) ? d : DECIDE_REASONS_FALLBACK;
decideReasons.accept = src.accept || DECIDE_REASONS_FALLBACK.accept;
decideReasons.decline = src.decline || DECIDE_REASONS_FALLBACK.decline;
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:'' });
const decideDlg = reactive({ visible:false, busy:false, row:null, decision:'', code:'', note:'', message:'', updated:false, uncertain:false });
// 采纳/驳回都从预设理由里选一条 (选「其他」再补一句), 不再手写整段 (三源合议自决后剩的都是真交人的)。
function decide(row, decision) {
if (decideDlg.busy) return;
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 });
}
const decideCodes = computed(() =>
decideDlg.decision === 'ACCEPTED' ? decideReasons.accept : decideReasons.decline);
async function submitDecide() {
const dd = decideDlg;
if (dd.busy || dd.uncertain) return;
if (!dd.code && !dd.note.trim()) { ElementPlus.ElMessage.warning('选一个理由,或补一句说明'); return; }
if (dd.code && dd.code.endsWith('_OTHER') && !dd.note.trim()) {
ElementPlus.ElMessage.warning('选「其他」时必须填补充说明'); return; }
dd.busy = true;
dd.message = '';
try {
const d = await call('post', '/api/proposals/' + dd.row.proposal_id + '/decide',
{ decision: dd.decision, reason_code: dd.code || null, reason_note: dd.note.trim() || null,
proposal_revision: (dd.row.hard_numbers || {}).proposal_revision || null });
dd.busy = false;
proposal_revision: (dd.row.hard_numbers || {}).proposal_revision || null },
{ timeout: decideTimeoutMs });
if (d.ok) {
ElementPlus.ElMessage.success(dd.decision === 'ACCEPTED'
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
ElementPlus.ElMessage.success(dd.decision === 'ACCEPTED' ? '已采纳,请查看当日指令' : '已驳回');
if (d.warning) ElementPlus.ElMessage.warning(d.warning);
dd.visible = false;
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
} else { ElementPlus.ElMessage.error(d.error); if (d.proposal) { dd.visible = false; await loadProposals(); } }
} else {
dd.uncertain = !!d.uncertain;
dd.message = dd.uncertain
? '尚未收到处理结果。请关闭后刷新,先核对待确认提议和当日指令,勿立即重复提交。'
: (d.error || '本次未能采纳,请稍后再试');
if (d.proposal) {
dd.row = { ...dd.row, ...d.proposal };
dd.updated = !((d.proposal.hard_numbers||{}).last_accept_check);
if (dd.updated) { dd.code = ''; dd.note = ''; }
await loadProposals();
}
}
} catch (e) {
dd.uncertain = true;
dd.message = '尚未收到处理结果。请关闭后刷新,先核对待确认提议和当日指令,勿立即重复提交。';
} finally { dd.busy = false; }
}
async function loadStrategies() {
const q = '/api/strategies' + (showAllStrategies.value ? '?include_archived=1' : '');
const d = await call('get', q);

View File

@ -7,7 +7,7 @@
包含 (例数按 2026-10-12 隔离回归实跑校正):
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留 (18 例)
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明 (24 例)
含在途报价变化不重复确认、真实承诺变化仍重核。
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)

View File

@ -148,6 +148,83 @@ class DailyPlanTest(unittest.TestCase):
self.assertTrue(route.call_args.kwargs['review_only'])
self.assertTrue(c['_plan_recheck'])
def test_rejected_recheck_preserves_reason_without_accepting_or_upgrading_basis(self):
from app.services import plan_recheck as rc, proposal_service as ps, param_store
from app.repo import pms_repo
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': '600001.SH', 'qty': 300,
'hard_numbers_json': '{}', 'hard_numbers': {'proposal_revision': 'old', 'plan_basis': {'old': True}}}
c = {'qty': 100, 'ts_code': p['ts_code'], 'hard_numbers': {'price': 10, 'plan_basis': {'new': True}}}
def reject(*args, **kw):
args[7]['rejected'].append({'ts_code': p['ts_code'], 'by': 'judge', 'failed': ['资金净流出,短期看空']})
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(param_store, 'get_int', return_value=0), \
patch.object(pms_repo, 'record_proposal_check', return_value=1) as record, \
patch.object(pms_repo, 'revise_pending_proposal', side_effect=AssertionError('不得升级原批准依据')), \
patch.object(pms_repo, 'insert_instruction', side_effect=AssertionError('不得生成指令')):
out = rc.before_accept(p, 'old')
self.assertFalse(out['ok'])
self.assertIn('最新研判不建议建仓:资金净流出,短期看空', out['error'])
hard = record.call_args.args[1]
self.assertEqual(hard['plan_basis'], {'old': True})
self.assertEqual(hard['proposal_revision'], 'old')
self.assertEqual(out['proposal']['qty'], 300)
self.assertEqual(hard['last_accept_check']['stage'], 'judge')
def test_blocked_recheck_distinguishes_rule_strategy_and_scan(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': {}}
examples = [({'rejected': [{'by': 'rule', 'failed': ['CASH: 可用资金不足']}]}, '交易条件不满足:可用资金不足'),
({'rejected': [{'by': 'system', 'why': '逻辑存疑'}]}, '当前策略不建议建仓:逻辑存疑'),
({'skipped': [{'why': '没有可用名额'}]}, '暂不满足建仓条件:没有可用名额')]
with patch.object(pms_repo, 'record_proposal_check', return_value=1):
for details, reason in examples:
out = rc._failed_recheck(p, details)
self.assertEqual(out['error'], reason)
self.assertFalse(out['ok'])
def test_failed_recheck_does_not_overwrite_concurrent_proposal(self):
from app.services import plan_recheck as rc
from app.repo import pms_repo
with patch.object(pms_repo, 'record_proposal_check', return_value=0):
out = rc._failed_recheck({'proposal_id': 'p1', 'ts_code': '600001.SH'}, {})
self.assertFalse(out['ok'])
self.assertNotIn('proposal', out)
self.assertIn('已被更新', out['error'])
def test_record_check_is_conditional_and_keeps_quantity_status_and_verdict(self):
import sqlite3
from app.repo import pms_repo
db = sqlite3.connect(':memory:')
self.addCleanup(db.close)
db.execute('CREATE TABLE pms_proposal(proposal_id TEXT, status TEXT, qty INTEGER, judge_verdict TEXT, hard_numbers_json TEXT)')
db.execute("INSERT INTO pms_proposal VALUES ('p1','WAIT_USER',300,'UNAVAILABLE','old')")
with patch.object(pms_repo, 'execute', side_effect=lambda q, p: db.execute(q,p).rowcount):
old = {'proposal_id':'p1','hard_numbers_json':'old'}
self.assertEqual(pms_repo.record_proposal_check(old, {'check':'blocked'}), 1)
self.assertEqual(pms_repo.record_proposal_check(old, {'check':'stale'}), 0)
self.assertEqual(db.execute('SELECT qty,status,judge_verdict FROM pms_proposal').fetchone(), (300,'WAIT_USER','UNAVAILABLE'))
def test_previous_rejection_cannot_reuse_old_approval_when_context_returns(self):
from app.services import plan_recheck as rc, proposal_service as ps
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': '600001.SH',
'hard_numbers': {'proposal_revision': 'old', 'last_accept_check': {'status': 'blocked'}}}
with patch.object(rc, 'current_basis', return_value={'ok': True, 'material': False,
'view': {'held': []}, 'params': {}, 'stock': {}}), \
patch.object(ps, '_scan_open', return_value=[]) as scan, \
patch.object(rc, '_failed_recheck', return_value={'ok': False}) as blocked:
self.assertFalse(rc.before_accept(p, 'old')['ok'])
scan.assert_called_once()
blocked.assert_called_once()
def test_browser_wait_covers_effective_judge_timeout(self):
from app.web import main
with patch.object(main.param_store, 'get_int', return_value=240):
out = main.api_decide_reasons()
self.assertGreater(out['request_timeout_ms'], 240000)
self.assertTrue(out['accept'])
def test_version_unavailable_and_qualification_loss_preserve_proposal(self):
from app.services import plan_recheck as rc
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {}}

View File

@ -0,0 +1,29 @@
// 本地:node scripts/test_proposal_dialog.cjs。内存替身,不发网络请求。
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const html=fs.readFileSync(require('path').join(__dirname,'../app/web/static/index.html'),'utf8');
const block=html.slice(html.indexOf(' const decideDlg = reactive('),html.indexOf(' async function loadStrategies()'));
function make(response){
let n=0,resolve;
const ctx={reactive:x=>x,computed:f=>f,decideReasons:{accept:{a:'why'},decline:{}},loadDecideReasons:()=>{},
ElementPlus:{ElMessage:{warning:()=>{},success:()=>{},error:()=>{throw Error('failure must stay visible')}}},
loadProposals:async()=>{},loadInstructions:async()=>{},loadLedger:async()=>{},decideTimeoutMs:150000,
call:async(m,u,p,c)=>{n++;assert.equal(c.timeout,150000);return typeof response==='function'?response(p):response}};
vm.createContext(ctx);vm.runInContext(block+';this.t={decideDlg,decide,submitDecide}',ctx);
return {...ctx.t,count:()=>n};
}
(async()=>{
const row={proposal_id:'p1',qty:300,hard_numbers:{proposal_revision:'old'}};
let t=make({ok:false,error:'最新研判不建议建仓:资金净流出',proposal:{...row,hard_numbers:{...row.hard_numbers,last_accept_check:{message:'资金净流出'}}}});
t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';await t.submitDecide();
assert(t.decideDlg.visible);assert(!t.decideDlg.busy);assert(t.decideDlg.message.includes('资金净流出'));assert(!t.decideDlg.updated);
t=make({ok:false,error:'请确认更新后的建议',proposal:{...row,qty:100,judge_reason:'新理由',hard_numbers:{proposal_revision:'new'}}});
t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';await t.submitDecide();
assert(t.decideDlg.visible);assert(t.decideDlg.updated);assert.equal(t.decideDlg.row.qty,100);assert.equal(t.decideDlg.code,'');
t=make({ok:false,error:'timeout',uncertain:true});t.decide(row,'ACCEPTED');t.decideDlg.code='ACCEPT_CONSENSUS';await t.submitDecide();await t.submitDecide();
assert.equal(t.count(),1);assert(t.decideDlg.message.includes('尚未收到'));assert(!t.decideDlg.busy);
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=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);
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');
})().catch(e=>{console.error(e);process.exit(1)});