提议采纳:保留拦截原因与确认内容,完善等待及结果反馈(台账 072)
一、真实拒绝需要让用户看见原因 瑞芯微13:40研判拒绝只回通用错误,原提议仍展示旧结论。失败保留具体原因与检查记录,条件更新避免覆盖并发结果,原数量及批准依据保持。 二、更新内容必须重新确认 确认框展示新数量与理由并清空旧选择。等待期间阻止重复点击,超时或断网不自动重发。浏览器等待覆盖有效研判超时与取数;已有失败不能复用旧批准。 三、验证与边界 24个专项、44批本地隔离禁网回归及Node交互替身通过。未改准入、研判、仓位、执行规则与时段,部署仅限155模拟仓。候选镜像和线上验证另记,188不操作。
This commit is contained in:
parent
938fff2e0f
commit
4f6b1922f1
|
|
@ -1993,3 +1993,11 @@ bafecb7 经 45 策略用例、44 批本地及候选镜像禁网回归通过,11
|
||||||
## 2026-10-16 13:35 · 用户确认时延口径与自然提议重核
|
## 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。
|
用户明确十二分钟按连续交易时段核验,午休材料开盘后及时消费即可。八份盘中样本耗时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,不能仅回退镜像。
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,14 @@ def revise_pending_proposal(old, candidate, verdict):
|
||||||
'pid': old['proposal_id'], 'old': old['hard_numbers_json']})
|
'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:
|
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'" +
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""采纳前核验;复用原扫描、合议、研判与规则闸,不解释自然语言。"""
|
"""采纳前核验;复用原扫描、合议、研判与规则闸,不解释自然语言。"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import re
|
||||||
from app.core.plan_revision import compare, fingerprint, portfolio_context
|
from app.core.plan_revision import compare, fingerprint, portfolio_context
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,6 +33,34 @@ def current_basis(hard, code, proposal_id=None):
|
||||||
'reason': '依据相同,名次变化不撤销'}}}
|
'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):
|
def before_accept(p, expected_revision):
|
||||||
from app.services import proposal_service as ps
|
from app.services import proposal_service as ps
|
||||||
from app.repo import pms_repo
|
from app.repo import pms_repo
|
||||||
|
|
@ -40,6 +69,9 @@ def before_accept(p, expected_revision):
|
||||||
return {'ok': True, 'proposal': p}
|
return {'ok': True, 'proposal': p}
|
||||||
try:
|
try:
|
||||||
check = current_basis(hard, p['ts_code'], proposal_id=p.get('proposal_id'))
|
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 check.get('ok'):
|
||||||
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}
|
||||||
|
|
@ -54,7 +86,7 @@ def before_accept(p, expected_revision):
|
||||||
fresh = ps._scan_open(view, params, check['stock'], {}, mkt, out, now=now,
|
fresh = ps._scan_open(view, params, check['stock'], {}, mkt, out, now=now,
|
||||||
review_code=p['ts_code'], exclude_proposal=p['proposal_id'])
|
review_code=p['ts_code'], exclude_proposal=p['proposal_id'])
|
||||||
if not fresh:
|
if not fresh:
|
||||||
return {'ok': False, 'error': '重新评估未产出可采纳提议', 'details': out}
|
return _failed_recheck(p, out)
|
||||||
c = fresh[0]
|
c = fresh[0]
|
||||||
c['_plan_recheck'] = True
|
c['_plan_recheck'] = True
|
||||||
from app.services import param_store
|
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)
|
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)
|
result = ps._route_one(c, view, params, check['stock'], brake, now, True, out, review_only=True)
|
||||||
if not result:
|
if not result:
|
||||||
return {'ok': False, 'error': '新版重新评估未通过', 'details': out}
|
return _failed_recheck(p, out)
|
||||||
hn = c['hard_numbers']
|
hn = c['hard_numbers']
|
||||||
hn['version_check'] = {'status': 'reconfirmed_required', 'at': now.astimezone().isoformat(),
|
hn['version_check'] = {'status': 'reconfirmed_required', 'at': now.astimezone().isoformat(),
|
||||||
'reason': '依据变化,已重跑合议、研判、数量及规则闸'}
|
'reason': '依据变化,已重跑合议、研判、数量及规则闸'}
|
||||||
|
|
@ -73,6 +105,8 @@ def before_accept(p, expected_revision):
|
||||||
verdict='WARN', price_at=hn.get('price') or 0, hard_numbers=hn,
|
verdict='WARN', price_at=hn.get('price') or 0, hard_numbers=hn,
|
||||||
ref_id=p['proposal_id'], reason='新版依据变化,更新提议并要求重新确认')
|
ref_id=p['proposal_id'], reason='新版依据变化,更新提议并要求重新确认')
|
||||||
return {'ok': False, 'error': '提议已按新版重新评估,请查看更新内容后再次确认',
|
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:
|
except Exception as e:
|
||||||
return {'ok': False, 'error': f'版本核验暂不可用,保留提议:{type(e).__name__}'}
|
return {'ok': False, 'error': f'版本核验暂不可用,保留提议:{type(e).__name__}'}
|
||||||
|
|
|
||||||
|
|
@ -481,7 +481,9 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
|
||||||
def api_decide_reasons():
|
def api_decide_reasons():
|
||||||
"""裁决预设理由的两张码表 (采纳 / 驳回), 页面据此铺按钮。台账 015。"""
|
"""裁决预设理由的两张码表 (采纳 / 驳回), 页面据此铺按钮。台账 015。"""
|
||||||
return {"ok": True, "accept": decide_reasons.ACCEPT_REASONS,
|
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")
|
@app.post("/api/proposals/{proposal_id}/decide")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<span class="sub">{{ propTimingSub(p) }}</span><i class="car">›</i>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="prop-actions">
|
||||||
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
|
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
|
||||||
<el-button type="danger" size="small" @click="decide(p,'DECLINED')">驳回</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): 采纳/驳回从预设理由里选, 选其他再补一句。 -->
|
<!-- 裁决弹窗 (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"
|
||||||
:title="(decideDlg.decision==='ACCEPTED'?'采纳':'驳回') + ' · ' + (decideDlg.row ? nm(decideDlg.row.ts_code) : '') + (decideDlg.row ? ' ' + tx('action', decideDlg.row.action) : '')">
|
: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>
|
<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>
|
||||||
|
|
@ -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"
|
<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 #footer>
|
<template #footer>
|
||||||
<el-button @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" @click="submitDecide">
|
<el-button :type="decideDlg.decision==='ACCEPTED'?'success':'danger'" :loading="decideDlg.busy" :disabled="decideDlg.uncertain" @click="submitDecide">
|
||||||
{{ decideDlg.decision==='ACCEPTED'?'确认采纳':'确认驳回' }}</el-button>
|
{{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? (((decideDlg.row||{}).hard_numbers||{}).last_accept_check ? '重新核验' : '确认采纳') : '确认驳回')) }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
|
@ -3704,9 +3717,9 @@ createApp({
|
||||||
// ===================== /交易员视图 =====================
|
// ===================== /交易员视图 =====================
|
||||||
|
|
||||||
|
|
||||||
async function call(method, url, body) {
|
async function call(method, url, body, options) {
|
||||||
try {
|
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 || '请求失败'; }
|
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
|
||||||
return r.data;
|
return r.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -3716,7 +3729,7 @@ createApp({
|
||||||
const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要系统管理员';
|
const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要系统管理员';
|
||||||
ElementPlus.ElMessage.error(m); return { ok: false, error: m };
|
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:'其他' },
|
DECLINE_WAIT_PULLBACK:'等待回调再议', DECLINE_MARKET_RISK:'大盘风险偏高', DECLINE_OTHER:'其他' },
|
||||||
};
|
};
|
||||||
const decideReasons = reactive({ accept:{}, decline:{} });
|
const decideReasons = reactive({ accept:{}, decline:{} });
|
||||||
|
let decideTimeoutMs = 180000;
|
||||||
async function loadDecideReasons() {
|
async function loadDecideReasons() {
|
||||||
const d = await call('get', '/api/proposals/decide-reasons');
|
const d = await call('get', '/api/proposals/decide-reasons');
|
||||||
const src = (d && d.ok !== false) ? d : DECIDE_REASONS_FALLBACK;
|
const src = (d && d.ok !== false) ? d : DECIDE_REASONS_FALLBACK;
|
||||||
decideReasons.accept = src.accept || DECIDE_REASONS_FALLBACK.accept;
|
decideReasons.accept = src.accept || DECIDE_REASONS_FALLBACK.accept;
|
||||||
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);
|
||||||
}
|
}
|
||||||
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) {
|
function decide(row, decision) {
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
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.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; }
|
||||||
dd.busy = true;
|
dd.busy = true;
|
||||||
const d = await call('post', '/api/proposals/' + dd.row.proposal_id + '/decide',
|
dd.message = '';
|
||||||
{ decision: dd.decision, reason_code: dd.code || null, reason_note: dd.note.trim() || null,
|
try {
|
||||||
proposal_revision: (dd.row.hard_numbers || {}).proposal_revision || null });
|
const d = await call('post', '/api/proposals/' + dd.row.proposal_id + '/decide',
|
||||||
dd.busy = false;
|
{ decision: dd.decision, reason_code: dd.code || null, reason_note: dd.note.trim() || null,
|
||||||
if (d.ok) {
|
proposal_revision: (dd.row.hard_numbers || {}).proposal_revision || null },
|
||||||
ElementPlus.ElMessage.success(dd.decision === 'ACCEPTED'
|
{ timeout: decideTimeoutMs });
|
||||||
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
|
if (d.ok) {
|
||||||
if (d.warning) ElementPlus.ElMessage.warning(d.warning);
|
ElementPlus.ElMessage.success(dd.decision === 'ACCEPTED' ? '已采纳,请查看当日指令' : '已驳回');
|
||||||
dd.visible = false;
|
if (d.warning) ElementPlus.ElMessage.warning(d.warning);
|
||||||
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
|
dd.visible = false;
|
||||||
} else { ElementPlus.ElMessage.error(d.error); if (d.proposal) { dd.visible = false; await loadProposals(); } }
|
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() {
|
async function loadStrategies() {
|
||||||
const q = '/api/strategies' + (showAllStrategies.value ? '?include_archived=1' : '');
|
const q = '/api/strategies' + (showAllStrategies.value ? '?include_archived=1' : '');
|
||||||
const d = await call('get', q);
|
const d = await call('get', q);
|
||||||
|
|
|
||||||
|
|
@ -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 每日计划顺序、版本核验、重新确认、并发采纳、历史保留 (18 例)
|
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明 (24 例)
|
||||||
含在途报价变化不重复确认、真实承诺变化仍重核。
|
含在途报价变化不重复确认、真实承诺变化仍重核。
|
||||||
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
||||||
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,83 @@ class DailyPlanTest(unittest.TestCase):
|
||||||
self.assertTrue(route.call_args.kwargs['review_only'])
|
self.assertTrue(route.call_args.kwargs['review_only'])
|
||||||
self.assertTrue(c['_plan_recheck'])
|
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):
|
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': {}}
|
||||||
|
|
|
||||||
|
|
@ -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)});
|
||||||
Loading…
Reference in New Issue