diff --git a/DEVLOG.md b/DEVLOG.md
index 5d1b476..2628d43 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -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,不能仅回退镜像。
diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py
index 64781c8..520c9c9 100644
--- a/app/repo/pms_repo.py
+++ b/app/repo/pms_repo.py
@@ -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'" +
diff --git a/app/services/plan_recheck.py b/app/services/plan_recheck.py
index 95519e9..753e57c 100644
--- a/app/services/plan_recheck.py
+++ b/app/services/plan_recheck.py
@@ -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__}'}
diff --git a/app/web/main.py b/app/web/main.py
index 589fdb9..416f07b 100644
--- a/app/web/main.py
+++ b/app/web/main.py
@@ -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")
diff --git a/app/web/static/index.html b/app/web/static/index.html
index 2661dd0..9ba4f1d 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -1273,6 +1273,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ propTimingSub(p) }}›
+
采纳
驳回
@@ -1408,7 +1411,17 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
+
+ {{ decideDlg.row.qty }} 股预计金额 {{ propAmount(decideDlg.row) }}
+
+
+ {{ decideDlg.row.judge_reason || propWhy(decideDlg.row) }}
+
+ 正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。
+
选一个理由记进评审账本。选「其他」时补充必填。
{{ label }}
@@ -1416,9 +1429,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
- 取消
-
- {{ decideDlg.decision==='ACCEPTED'?'确认采纳':'确认驳回' }}
+ 关闭
+
+ {{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? (((decideDlg.row||{}).hard_numbers||{}).last_accept_check ? '重新核验' : '确认采纳') : '确认驳回')) }}
@@ -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;
- 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;
- if (d.ok) {
- ElementPlus.ElMessage.success(dd.decision === 'ACCEPTED'
- ? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
- 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(); } }
+ 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 },
+ { timeout: decideTimeoutMs });
+ if (d.ok) {
+ ElementPlus.ElMessage.success(dd.decision === 'ACCEPTED' ? '已采纳,请查看当日指令' : '已驳回');
+ if (d.warning) ElementPlus.ElMessage.warning(d.warning);
+ dd.visible = false;
+ await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
+ } 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);
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index d7850cf..92bae90 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -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 例)
diff --git a/scripts/test_batch38_units.py b/scripts/test_batch38_units.py
index 1427e88..0325d98 100644
--- a/scripts/test_batch38_units.py
+++ b/scripts/test_batch38_units.py
@@ -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': {}}
diff --git a/scripts/test_proposal_dialog.cjs b/scripts/test_proposal_dialog.cjs
new file mode 100644
index 0000000..6803789
--- /dev/null
+++ b/scripts/test_proposal_dialog.cjs
@@ -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(/