提议失效不跨自然日、盘前与首轮扫描清扫过期提议;管理视图持仓总览加状态·下一步列;选股计划标成数据日

待确认提议原来 24 小时一刀切,09:31 出的到次日 09:31 才失效,开盘前十分钟页面还挂着昨天的。
改成有效期不跨自然日,最晚当天 23:59:59;08:50 盘前准备与每轮扫描开头先把过期的置 EXPIRED,
提议列表接口按失效时刻再挡一道,裁决接口拒绝已过失效时刻的提议。第二十批加两例,共 902 例。
管理视图持仓总览的「状态」「冻结」两列并成「状态 · 下一步」,与交易员视图同款芯片、短语与悬停说明。
选股计划的日期是数据日(上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划,08:40 拉取),
原来只印日期让人以为没更新,顶栏、候选栏、计划抽屉改成「数据至 X · 今日 08:40 拉取」并加说明气泡。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-16 09:40:41 +08:00
parent 95665b067a
commit 03e4dd53b1
8 changed files with 129 additions and 19 deletions

View File

@ -1212,6 +1212,11 @@ def premarket() -> dict:
out["refs"] += 1
except Exception as e:
out["errors"].append(f"{code} 参考位取数失败: {e}")
try:
# 隔夜过期的待确认提议在盘前就置 EXPIRED (2026-09-16): 失效时刻不跨自然日, 开盘前页面不该还挂着昨天的。
out["expired_proposals"] = pms_repo.expire_proposals()
except Exception as e:
out["errors"].append(f"过期提议清扫失败: {e}")
try:
out["brake"] = _settle_brake()
# 「该踩没踩上」必须冒到盘前准备的 errors 里 —— 它不是一条 warning, 是风控没生效

View File

@ -226,7 +226,7 @@ DESC = {
"PMS_BUILD_WINDOW_TDAYS": "建仓期窗口 (交易日)",
"PMS_FILL_MAX_LOSS": "浮亏深于此不走回踩补足",
"PMS_WEAK_NEG_DAYS": "降仓清弱票: 安全垫连续为负天数",
"PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时)",
"PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时); 不跨自然日, 最晚当天 23:59:59 失效",
"PMS_CANDIDATE_SOURCE": "升仓候选池来源: plan_api=上游选股计划接口 / buy_plan=旧 trading_buy_plan 表 / both=并集",
"PMS_PLAN_API_BASE": "上游选股计划接口根地址; 留空=停用, 此时候选池恒为空并告警",
"PMS_PLAN_API_PATH": "计划接口路径 (默认 /plan)",

View File

@ -83,6 +83,14 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
open_autonomy = param_store.get("PMS_OPEN_AUTONOMY", AUTONOMY_FULL)
out["open_autonomy"] = open_autonomy
if not dry_run:
# 过期提议先置 EXPIRED (2026-09-16): 失效时刻不跨自然日之后, 隔夜的提议要在开盘前第一轮扫描
# 就清掉, 不能等 15:10 结算; 也免得下面的跳过集合把一条早该作废的提议当在途。只读试算不动表。
try:
out["expired_proposals"] = pms_repo.expire_proposals(now)
except Exception as e: # noqa: BLE001 —— 清扫失败不挡扫描
out["errors"].append(f"过期提议清扫失败: {e}")
try:
view = portfolio.positions_view()
params = _scan_params(view)
@ -1319,6 +1327,34 @@ def _sd_decline_ledger(code, action, c, verdict, price, sd) -> None:
reason=(sd.get("why") or "系统自决放弃")[:500])
def proposal_expire_at(now=None, ttl_hours=None):
"""待确认提议的失效时刻 (2026-09-16 用户拍板): 有效期 PMS_PROPOSAL_TTL_HOURS 小时, 但**不跨自然日**。
提议是按当天的证据 (合议研判现价) 出的, 隔夜再采纳等于拿昨天的判断下今天的单原来 24 小时
一刀切, 09:31 出的提议到次日 09:31 才失效, 开盘前十分钟页面还挂着昨天的; 用户要求昨天的提议
在自然日结束前失效TTL 短于当天剩余时间时照旧按 TTL (给短有效期留着)"""
now = now or datetime.now()
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24) if ttl_hours is None else ttl_hours
day_end = now.replace(hour=23, minute=59, second=59, microsecond=0)
return min(now + timedelta(hours=ttl), day_end)
def _as_dt(v):
if v is None or isinstance(v, datetime):
return v
try:
return datetime.fromisoformat(str(v).replace(" ", "T"))
except ValueError:
return None
def is_expired(p: dict, now=None) -> bool:
"""这条提议是否已过失效时刻 (状态可能还没被清扫成 EXPIRED)。认 datetime 与「YYYY-MM-DD HH:MM:SS」
两种写法; 没有失效时刻或认不出按未过期 不替人作废"""
exp = _as_dt((p or {}).get("expire_at"))
return bool(exp) and exp < (now or datetime.now())
def _make_proposal(c, price, verdict) -> str:
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}"
@ -1343,7 +1379,7 @@ def _make_proposal(c, price, verdict) -> str:
try:
pms_repo.insert_proposal(
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],
hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl),
hard_numbers=hn, expire_at=proposal_expire_at(ttl_hours=ttl),
judge_verdict=verdict.get("verdict"),
judge_reason=(verdict.get("reason") or "")[:500])
except Exception:
@ -1353,7 +1389,7 @@ def _make_proposal(c, price, verdict) -> str:
pid = f"{pid}_{datetime.now().strftime('%H%M%S')}"
pms_repo.insert_proposal(
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],
hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl),
hard_numbers=hn, expire_at=proposal_expire_at(ttl_hours=ttl),
judge_verdict=verdict.get("verdict"),
judge_reason=(verdict.get("reason") or "")[:500])
return pid

View File

@ -463,6 +463,10 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
def _list():
rows = pms_repo.list_proposals(statuses=statuses, limit=limit,
include_archived=include_archived)
# 过了失效时刻但还没被清扫成 EXPIRED 的, 页面不再列出 (2026-09-16): 失效不跨自然日之后,
# 隔夜提议要到 08:50 盘前或首轮扫描才置 EXPIRED, 这里按时刻再挡一道, 免得开盘前还挂着昨天的。
from app.services import proposal_service as psvc
rows = [r for r in rows if r.get("status") != "WAIT_USER" or not psvc.is_expired(r)]
# 技术面立场 (2026-09-11): 每条提议挂 tech提议卡第四行结论用取不到不塌。
try:
from app.services import tech_service
@ -505,6 +509,14 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
return {"ok": False, "error": "提议不存在"}
if p["status"] != "WAIT_USER":
return {"ok": False, "error": f"提议已处于 {p['status']}"}
# 过了失效时刻的不能再裁决 (2026-09-16): 状态还没被清扫成 EXPIRED 也一样, 顺手清一遍。
from app.services import proposal_service as psvc
if psvc.is_expired(p):
try:
pms_repo.expire_proposals()
except Exception as e: # noqa: BLE001
logger.warning("[提议] 过期清扫失败: %s", e)
return {"ok": False, "error": "提议已过失效时刻, 不能再裁决; 该票由系统重新评估"}
if not pms_repo.decide_proposal(proposal_id, decision):
return {"ok": False, "error": "提议状态已变更, 请刷新"}
hn = p.get("hard_numbers") or {}
@ -564,13 +576,14 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
def api_create_proposal(payload: dict = Body(...)):
"""人工补录一条待确认提议 (影子运行期造数与联调用)。"""
def _create():
from app.services import proposal_service as psvc
pid = payload.get("proposal_id") or f"PRP_{td.ymd()}_{int(datetime.now().timestamp())}"
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
pms_repo.insert_proposal(
proposal_id=pid, ts_code=cs.normalize_code(payload.get("ts_code") or ""),
action=payload.get("action") or "ADD", qty=int(payload.get("qty") or 0),
hard_numbers=payload.get("hard_numbers") or {},
expire_at=datetime.now() + timedelta(hours=ttl))
expire_at=psvc.proposal_expire_at(ttl_hours=ttl)) # 与自主提议同一条规矩: 不跨自然日
return {"ok": True, "proposal_id": pid}
return ok(_create)

View File

@ -334,8 +334,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
.postbl th.el-table-fixed-column--right{background:var(--surface-2)!important;}
.postbl .el-table__body tr:hover>td.el-table-fixed-column--right,
.postbl .el-table__fixed-right .el-table__row:hover>.el-table__cell{background:var(--surface-2)!important;}
.postbl .chips{display:flex;flex-wrap:wrap;gap:4px;align-items:center;}
.postbl .nx{margin-top:3px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:default;}
.chips{display:flex;flex-wrap:wrap;gap:4px;align-items:center;}
.nx{margin-top:3px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:default;}
.postbl .morebtn{padding:4px 9px;font-weight:700;letter-spacing:1px;}
/* ── 左栏 信号流 ── */
@ -491,7 +491,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="chip hd-buy" :class="ov.buy_halt?'warn':'ok'"><span class="dot"></span>买入 {{ ov.buy_halt ? '已暂停':'正常' }}</span>
<span class="chip hd-exec" :class="ov.exec_halt?'warn':'ok'"><span class="dot"></span>执行 {{ ov.exec_halt ? '已暂停':'正常' }}</span>
<span class="chip" :class="ov.autonomy==='off'?'warn':'ok'"><span class="dot"></span>自主 {{ tx('autonomy', ov.autonomy) }}<span class="help" data-tip="系统能自主到什么程度。全自动=各项检查通过即直接下单;仅提议=先在「待确认提议」等人工确认;暂停执行=系统不自动动作。">?</span></span>
<span class="chip hd-plan" :class="planStatus.ok===false?'warn':'ok'"><span class="dot"></span>选股计划<span class="hd-plan-d"> {{ planStatus.ok===false ? '读不到' : (planStatus.date || '-') }}</span><span class="hd-plan-d" v-if="planStatus.ok!==false && planStatus.plan_source"> · {{ planStatus.plan_source==='snapshot'?'快照':(planStatus.plan_source==='live'?'现算':planStatus.plan_source) }}<span v-if="planStatus.gate_on===true"> · 赛道闸开</span><span v-else-if="planStatus.gate_on===false"> · 赛道闸关</span><span class="help" data-tip="计划来源:快照=盘前落盘那份,毫秒返回、可复现;现算=盘中实时装配,较慢,多半是快照过旧回落。赛道闸=上游是否只在十条赛道内选票。">?</span></span></span>
<!-- 计划日期是数据日 (2026-09-16 说明): 上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划, 持仓系统 08:40 拉取,
所以正常显示的是上一交易日; 原来只印日期, 用户以为计划没更新。拉取时刻按持仓系统自己记的时间戳显示。 -->
<span class="chip hd-plan" :class="planStatus.ok===false?'warn':'ok'"><span class="dot"></span>选股计划<span class="hd-plan-d"> {{ planStatus.ok===false ? '读不到' : ('数据至 ' + (planStatus.date || '-')) }}</span><span class="hd-plan-d" v-if="planStatus.ok!==false && planFetchedText"> · {{ planFetchedText }}</span><span class="hd-plan-d" v-if="planStatus.ok!==false && planStatus.plan_source"> · {{ planStatus.plan_source==='snapshot'?'快照':(planStatus.plan_source==='live'?'现算':planStatus.plan_source) }}<span v-if="planStatus.gate_on===true"> · 赛道闸开</span><span v-else-if="planStatus.gate_on===false"> · 赛道闸关</span><span class="help" data-tip="日期是数据日:上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划,持仓系统 08:40 拉取,所以正常显示的是上一交易日。计划来源:快照=盘前落盘那份,毫秒返回、可复现;现算=盘中实时装配,较慢,多半是快照过旧回落。赛道闸=上游是否只在十条赛道内选票。">?</span></span></span>
<span class="chip hd-tech" :class="techChipCls"><span class="dot"></span>技术面<span class="hd-plan-d"> {{ techChipText }}</span><span class="help" data-tip="每晚算的技术面读数布林线、多空布林线、SAR是哪天的。绿今天的映射新鲜读不到或已过期技术面这一层没开。">?</span></span>
<el-tooltip placement="bottom-start" effect="light" :show-after="120">
<template #content>
@ -1295,7 +1297,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="secth" @click="toggleSect('pool')">建仓候选
<span class="n">· {{ watchCandidates.length }} 只</span><span class="car">{{ sectClosed.pool?'▸':'▾' }}</span></div>
<div class="sectb">
<div class="muted" style="margin-bottom:5px">今天选股计划里、系统仍在评估、可能建仓的票。计划日 {{ planStatus.date || '—' }}。
<div class="muted" style="margin-bottom:5px">今天选股计划里、系统仍在评估、可能建仓的票。计划数据至 {{ planStatus.date || '—' }}<template
v-if="planFetchedText">{{ planFetchedText }}</template>
已下单、待确认、未建仓的,分别在「当日指令」和「待确认提议」。</div>
<div class="pool-note" v-if="(openScan.notes||[]).length">
<span v-for="(n,i) in openScan.notes" :key="i">{{ n }}<span v-if="i < openScan.notes.length-1"></span></span></div>
@ -1721,12 +1724,17 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="sector" label="行业" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="90"></el-table-column>
<el-table-column label="冻结" width="110">
<!-- 状态 · 下一步 (2026-09-16): 与交易员视图持仓表同款 —— 状态、交易方案、只卖不买三种芯片一行,
下一步短语一行, 具体说明进悬停; 原来的「状态」「冻结」两列并进来。 -->
<el-table-column label="状态 · 下一步" min-width="170">
<template #default="s">
<el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small"
type="danger" effect="dark">{{ tx('frozen', s.row.frozen_reason) }}</el-tag>
<span v-else class="muted"></span>
<div class="chips">
<span class="chip idle"><span class="dot"></span>{{ tx('posStatus', s.row.status) }}</span>
<span v-if="stratOf(s.row.ts_code)" class="chip" :class="stratOf(s.row.ts_code).status==='ACTIVE' ? 'ok' : 'warn'"
:title="tx('stype', stratOf(s.row.ts_code).type) + '方案' + tx('stratStatus', stratOf(s.row.ts_code).status) + ' · ' + stratStateText(stratOf(s.row.ts_code))"><span class="dot"></span>{{ tx('stype', stratOf(s.row.ts_code).type) }}</span>
<span v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" class="chip warn" :title="tx('frozen', s.row.frozen_reason)"><span class="dot"></span>只卖不买</span>
</div>
<div class="nx" :title="nextStepWhy(s.row)">{{ nextStep(s.row) }}</div>
</template>
</el-table-column>
<el-table-column prop="total_qty" label="持仓" width="90"></el-table-column>
@ -2327,7 +2335,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false"
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
<div class="muted" v-else style="margin-bottom:8px">
计划日 <b>{{ planStatus.date }}</b> (日龄 {{ planStatus.age_tdays }} 交易日) ·
数据日 <b>{{ planStatus.date }}</b>(上游每个交易日早上用上一交易日收盘后的证据出计划,日龄 {{ planStatus.age_tdays }} 交易日<template
v-if="planStatus.age_tdays===1">,属正常</template><template v-if="planFetchedText"> · {{ planFetchedText }}</template> ·
热度日 {{ planStatus.heat_date || '-' }} · 快照 {{ (planStatus.market_snapshot_days||[]).join(',') }} ·
上游主题上限 {{ planStatus.theme_cap }} · theme 灌映射 {{ JSON.stringify(planStatus.theme_sync||{}) }}
<br>请求 {{ JSON.stringify(planStatus.query||{}) }} · 漏斗: 打分池
@ -2586,6 +2595,15 @@ createApp({
const planStatus = computed(() => planRaw.value.status || {});
const planRows = computed(() => planRaw.value.rows || []);
const planCand = computed(() => planRaw.value.candidates_raw || null);
// 计划拉取时刻 (2026-09-16): fetched_at 是持仓系统自己记的时间戳, 按浏览器本地时区显示, 不猜上游容器的时区。
const planFetchedText = computed(() => {
const ts = Number((planStatus.value || {}).fetched_at || 0); if (!ts) return '';
const d = new Date(ts * 1000); if (isNaN(d.getTime())) return '';
const hm = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
const t = new Date();
const same = d.getFullYear() === t.getFullYear() && d.getMonth() === t.getMonth() && d.getDate() === t.getDate();
return (same ? '今日 ' : (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + ' ') + hm + ' 拉取';
});
// 技术面模块状态 (2026-09-11): 顶栏新鲜度芯片。只读 /api/tech/status, 交易员也看得到。
const techStatus = ref({});
const techChipText = computed(() => {
@ -4574,7 +4592,7 @@ createApp({
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, decideDlg, decideCodes, submitDecide, ops, loadSchema, openReport, cancelIns,
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
planDrawer, planLoading, planStatus, planFetchedText, planRows, planCand, planResult,
loadPlan, openPlan, refreshPlan,
techStatus, techChipText, techChipCls,
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,

View File

@ -104,7 +104,7 @@ class Settings(BaseSettings):
PMS_BUILD_WINDOW_TDAYS: int = 10 # 建仓期窗口 (交易日)
PMS_FILL_MAX_LOSS: float = -0.03 # 浮亏深于此不走回踩补足 (转补仓域)
PMS_WEAK_NEG_DAYS: int = 5 # 降仓「清弱票」判定: 安全垫连续为负 N 日 (设计 §3.2)
PMS_PROPOSAL_TTL_HOURS: int = 24 # 自主提议待确认有效期 (超时置 EXPIRED)
PMS_PROPOSAL_TTL_HOURS: int = 24 # 自主提议待确认有效期 (超时置 EXPIRED); 不跨自然日, 最晚当天 23:59:59
# --- 自主新建仓 (动作引擎第五类动作 OPEN, 2026-08-06) ---
# 让自主提议除了管已有持仓, 也能从上游候选池里挑新票建底仓, 走同一条

View File

@ -52,7 +52,8 @@
仅展示的跳过原因写出这一只自己的判决依据/
候选一只不剩时的说明不打印计数字典/
新建仓低把握驳回按证据不足交人/
页面静态守卫 (18 )
提议失效不跨自然日 (2026-09-16 两例)/
页面静态守卫 (20 )
test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
@ -117,13 +118,14 @@
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价自决关时入队自决开时自动清仓按可卖量夹紧挂着方案且有在途买单时先撤再清 +
用户设的止损价与目标价单独成列显示 (72 )
900
902
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820;
09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例到 866; 页面收尾包第三十三批五例到 871;
09-14 系统自决包第三十四批二十二例test_wiring 目标价自动清仓加一例到 894;
09-14 选股打分包 §5.3 来源字段给第七批加两例到 896; 页面专业化包加文案守卫一例到 897;
09-14 评审补第三十四批两例test_wiring 撤方案与在途买单一例, 现为 900)
09-14 评审补第三十四批两例test_wiring 撤方案与在途买单一例到 900;
09-16 提议失效不跨自然日给第二十批加两例, 现为 902)
任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):

View File

@ -546,6 +546,42 @@ def _():
assert got[0]["judge_verdict"] == "PASS" and got[0]["judge_reason"] == "都成立"
@case("提议失效时刻不跨自然日 (2026-09-16): TTL 24 小时也在当天 23:59:59 失效; TTL 短于当天剩余时照旧按 TTL; "
"过期判定认 datetime 与字符串, 没有失效时刻按未过期")
def _():
from datetime import timedelta
from app.services import proposal_service as psvc
now = datetime(2026, 9, 15, 9, 31, 57)
assert psvc.proposal_expire_at(now, ttl_hours=24) == datetime(2026, 9, 15, 23, 59, 59)
assert psvc.proposal_expire_at(now, ttl_hours=2) == now + timedelta(hours=2)
late = datetime(2026, 9, 15, 23, 0, 0)
assert psvc.proposal_expire_at(late, ttl_hours=2) == datetime(2026, 9, 15, 23, 59, 59)
at = datetime(2026, 9, 16, 9, 20, 0)
assert psvc.is_expired({"expire_at": datetime(2026, 9, 15, 23, 59, 59)}, now=at) is True
assert psvc.is_expired({"expire_at": "2026-09-16 09:31:57"}, now=at) is False
assert psvc.is_expired({"expire_at": "2026-09-15 23:59:59"}, now=at) is True
assert psvc.is_expired({"expire_at": None}, now=at) is False
assert psvc.is_expired({}, now=at) is False
@case("_make_proposal 落表的失效时刻不晚于当天 23:59:59 (2026-09-16 用户拍板: 昨天的提议不留到今天开盘)")
def _():
from datetime import timedelta
from app.repo import pms_repo
from app.services import param_store, proposal_service as psvc
got = []
c = _open_cand()
with _Patch() as p:
p(pms_repo, "insert_proposal", lambda **kw: got.append(kw) or 1)
_patch_params(p, param_store, {"PMS_PROPOSAL_TTL_HOURS": 24})
psvc._make_proposal(c, 10.0, {"verdict": "PASS", "reason": "都成立", "degraded": False,
"raw": {}, "confidence": 72.0})
exp = got[0]["expire_at"]
now = datetime.now()
assert exp <= now.replace(hour=23, minute=59, second=59, microsecond=0), exp
assert exp >= now - timedelta(minutes=1), exp
# ================================================================ 页面静态守卫
@case("页面守卫·提议卡按 plan_rank 取候选榜名次并显示判决与理由; 采纳/驳回都弹必填理由并随请求体发")
def _():