From 60de23e79a03c514e6363cd3ac145569f50013d7 Mon Sep 17 00:00:00 2001 From: baobao Date: Fri, 16 Oct 2026 14:36:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=AE=A1=E5=88=92=EF=BC=9A?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E5=90=A6=E5=86=B3=E7=9A=84=E6=8F=90=E8=AE=AE?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=92=A4=E4=B8=8B=EF=BC=8C=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E5=92=8C=E5=B9=B6=E5=8F=91=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=EF=BC=88=E5=8F=B0=E8=B4=A6=20073=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、明确失效后结束旧建议。瑞芯微14:20自然重核返回REJECT,仍留待确认会误导用户;复用EXPIRED并记录撤下原因,暂时不可用继续守成。 二、撤下与拒绝留痕同事务,原状态和JSON防并发。沿用既有拒绝和重问,不调整准入、仓位或交易时段,不恢复旧批准。 三、30项专项和44批隔离禁网测试通过,含事务回滚、重复收尾及页面守卫;Node交互替身通过。候选镜像和155部署判收另记。 --- DEVLOG.md | 8 +++ app/db/session.py | 41 ++++++++++++++ app/repo/pms_repo.py | 6 +++ app/services/plan_feed.py | 1 + app/services/plan_recheck.py | 48 +++++++++++++---- app/services/proposal_service.py | 11 +++- app/web/main.py | 4 ++ app/web/static/index.html | 17 +++--- scripts/run_tests.py | 2 +- scripts/test_batch38_units.py | 93 ++++++++++++++++++++++++++++++-- scripts/test_proposal_dialog.cjs | 5 ++ 11 files changed, 215 insertions(+), 21 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 3dcbf6f..8584224 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2006,3 +2006,11 @@ bafecb7 经 45 策略用例、44 批本地及候选镜像禁网回归通过,11 ### 14:05部署补记 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不动。未改交易时段、准入参数、数量算法或研判结论。 diff --git a/app/db/session.py b/app/db/session.py index 4baad3e..1eef764 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -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='历史修复事务完成', 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'])} + + +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 diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py index 520c9c9..7533e18 100644 --- a/app/repo/pms_repo.py +++ b/app/repo/pms_repo.py @@ -533,6 +533,12 @@ def record_proposal_check(old, hard_numbers): '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: 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_feed.py b/app/services/plan_feed.py index 2503322..fc3e947 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -1169,6 +1169,7 @@ def candidates(*, held=(), black=(), route_by_verdict: bool = False, force=False rank_by_quality=p["rank_by_quality"], rank_by_upstream=p["rank_by_upstream"], observe_if_fund_bull=p["observe_if_fund_bull"]) + out['refresh_status'] = plan.get('refresh_status') out["age_tdays"] = plan.get("age_tdays") out["theme_cap"] = plan.get("theme_cap") out["route_by_verdict"] = route diff --git a/app/services/plan_recheck.py b/app/services/plan_recheck.py index 753e57c..7316987 100644 --- a/app/services/plan_recheck.py +++ b/app/services/plan_recheck.py @@ -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) if not (selected.get('decision') or {}).get('id'): 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: - return {'ok': False, 'error': '该票已不满足当前候选资格', 'material': True} + return {'ok': False, 'error': '该票已不满足当前候选资格', 'material': True, 'withdraw': True} result = compare(hard.get('plan_basis'), current, code) if result == 'unavailable': return {'ok': False, 'error': '当前计划修订或消费留痕尚未完成'} @@ -34,10 +37,13 @@ def current_basis(hard, code, proposal_id=None): 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']] + def relevant(rows): + 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 = [] for row in rows: values = row.get('failed') or [row.get('why')] @@ -49,16 +55,38 @@ def _failed_recheck(p, out): reasons.append(text) stage = rows[0].get('by', 'scan') if rows else 'scan' title = {'rule': '交易条件不满足', 'judge': '最新研判不建议建仓', - 'system': '当前策略不建议建仓'}.get(stage, '暂不满足建仓条件') + 'system': '当前策略不建议建仓', 'qualification': '候选条件已变化'}.get(stage, '暂不满足建仓条件') 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} 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': '提议已被更新,请刷新后查看当前结果', 'proposal_id': p['proposal_id']} - return {'ok': False, 'error': message, 'details': out, 'proposal_id': p['proposal_id'], - 'proposal': dict(p, hard_numbers=hard)} + return {'ok': False, 'withdrawn': withdrawn, + '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): @@ -76,6 +104,8 @@ def before_accept(p, expected_revision): if hard.get('proposal_revision') and expected_revision != hard['proposal_revision']: return {'ok': False, 'error': '提议内容已更新,请刷新后再次确认', 'proposal': p} 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'): return check now = datetime.now() diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index d8be527..009c039 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -88,6 +88,8 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: # 就清掉, 不能等 15:10 结算; 也免得下面的跳过集合把一条早该作废的提议当在途。只读试算不动表。 try: 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 —— 清扫失败不挡扫描 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"] if inflight["names"]: out["skipped"].append({"action": ae.A_OPEN, - "why": f"在途建仓已占 {len(inflight['names'])} 个名额、" + "informational": True, "why": f"在途建仓已占 {len(inflight['names'])} 个名额、" f"约 {inflight['amount']:,.0f} 元 " f"({sorted(inflight['names'])[:6]}), 本轮按余量扫描"}) # 真实可用资金封顶。**这一条比规则闸严一档, 是刻意的**: 规则闸那条「拿不到 ws 资金快照 @@ -876,6 +878,13 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, if verdict["verdict"] == judge.REJECT: out["rejected"].append({"ts_code": code, "action": action, "by": "judge", "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: # 驳回也把把握度记进硬数字 (2026-09-08): 低把握驳回转交人那道保险 (PMS_JUDGE_REJECT_CONF_MIN) # 是否在起作用, 只能从驳回行的把握度分布看出来; 此前只有提议行记它, 驳回行没有, 复核无据。 diff --git a/app/web/main.py b/app/web/main.py index 416f07b..584fef4 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -510,6 +510,10 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= if not p: return {"ok": False, "error": "提议不存在"} 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']}"} # 过了失效时刻的不能再裁决 (2026-09-16): 状态还没被清扫成 EXPIRED 也一样, 顺手清一遍。 from app.services import proposal_service as psvc diff --git a/app/web/static/index.html b/app/web/static/index.html index 9ba4f1d..2c9a925 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1412,8 +1412,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} -
+ :title="(decideDlg.withdrawn?'提议已撤下':(decideDlg.decision==='ACCEPTED'?'采纳':'驳回')) + ' · ' + (decideDlg.row ? nm(decideDlg.row.ts_code) : '') + (decideDlg.row ? ' ' + tx('action', decideDlg.row.action) : '')"> +
{{ decideDlg.row.qty }} 股预计金额 {{ propAmount(decideDlg.row) }}
正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。
+
@@ -4386,19 +4388,19 @@ createApp({ 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:'', 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) { if (decideDlg.busy) return; if (!Object.keys(decideReasons.accept).length) loadDecideReasons(); 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(() => decideDlg.decision === 'ACCEPTED' ? decideReasons.accept : decideReasons.decline); async function submitDecide() { 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.code.endsWith('_OTHER') && !dd.note.trim()) { ElementPlus.ElMessage.warning('选「其他」时必须填补充说明'); return; } @@ -4416,12 +4418,13 @@ createApp({ await Promise.all([loadProposals(), loadInstructions(), loadLedger()]); } else { dd.uncertain = !!d.uncertain; + dd.withdrawn = !!d.withdrawn; dd.message = dd.uncertain ? '尚未收到处理结果。请关闭后刷新,先核对待确认提议和当日指令,勿立即重复提交。' : (d.error || '本次未能采纳,请稍后再试'); if (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 = ''; } await loadProposals(); } diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 92bae90..0647472 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 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明 (24 例) + test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明、明确否决撤下 (30 例) 含在途报价变化不重复确认、真实承诺变化仍重核。 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 0325d98..abf48ad 100644 --- a/scripts/test_batch38_units.py +++ b/scripts/test_batch38_units.py @@ -159,7 +159,7 @@ class DailyPlanTest(unittest.TestCase): 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, 'withdraw_proposal', 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') @@ -178,10 +178,10 @@ class DailyPlanTest(unittest.TestCase): examples = [({'rejected': [{'by': 'rule', 'failed': ['CASH: 可用资金不足']}]}, '交易条件不满足:可用资金不足'), ({'rejected': [{'by': 'system', '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: out = rc._failed_recheck(p, details) - self.assertEqual(out['error'], reason) + self.assertEqual(out['error'].removeprefix('提议已撤下。'), reason) self.assertFalse(out['ok']) def test_failed_recheck_does_not_overwrite_concurrent_proposal(self): @@ -218,6 +218,70 @@ class DailyPlanTest(unittest.TestCase): scan.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): from app.web import main 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.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): 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 index 6803789..1b04914 100644 --- a/scripts/test_proposal_dialog.cjs +++ b/scripts/test_proposal_dialog.cjs @@ -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(); 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({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('')); + t.decide(row,'ACCEPTED');assert(!t.decideDlg.withdrawn); const script=html.match(/