From 4f6b1922f16afead73778515371200cf44017d3d Mon Sep 17 00:00:00 2001 From: baobao Date: Fri, 16 Oct 2026 13:56:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E8=AE=AE=E9=87=87=E7=BA=B3=EF=BC=9A?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=8B=A6=E6=88=AA=E5=8E=9F=E5=9B=A0=E4=B8=8E?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=86=85=E5=AE=B9=EF=BC=8C=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E5=8F=8A=E7=BB=93=E6=9E=9C=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=EF=BC=88=E5=8F=B0=E8=B4=A6=20072=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、真实拒绝需要让用户看见原因 瑞芯微13:40研判拒绝只回通用错误,原提议仍展示旧结论。失败保留具体原因与检查记录,条件更新避免覆盖并发结果,原数量及批准依据保持。 二、更新内容必须重新确认 确认框展示新数量与理由并清空旧选择。等待期间阻止重复点击,超时或断网不自动重发。浏览器等待覆盖有效研判超时与取数;已有失败不能复用旧批准。 三、验证与边界 24个专项、44批本地隔离禁网回归及Node交互替身通过。未改准入、研判、仓位、执行规则与时段,部署仅限155模拟仓。候选镜像和线上验证另记,188不操作。 --- DEVLOG.md | 8 ++++ app/repo/pms_repo.py | 8 ++++ app/services/plan_recheck.py | 40 +++++++++++++++-- app/web/main.py | 4 +- app/web/static/index.html | 73 ++++++++++++++++++++++-------- scripts/run_tests.py | 2 +- scripts/test_batch38_units.py | 77 ++++++++++++++++++++++++++++++++ scripts/test_proposal_dialog.cjs | 29 ++++++++++++ 8 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 scripts/test_proposal_dialog.cjs 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;}
@@ -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(/