修改前端页面部分问题
This commit is contained in:
parent
9a1b93a6d2
commit
7d25be41c9
|
|
@ -91,7 +91,8 @@ def get_command(command_id: str):
|
||||||
return _cmd_row(r) if r else None
|
return _cmd_row(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
def list_commands(*, statuses=None, cmd_class=None, limit: int = 200) -> list:
|
def list_commands(*, statuses=None, cmd_class=None, limit: int = 200,
|
||||||
|
include_archived: bool = False) -> list:
|
||||||
where, p = [], {"n": int(limit)}
|
where, p = [], {"n": int(limit)}
|
||||||
if statuses:
|
if statuses:
|
||||||
keys = []
|
keys = []
|
||||||
|
|
@ -102,6 +103,8 @@ def list_commands(*, statuses=None, cmd_class=None, limit: int = 200) -> list:
|
||||||
if cmd_class:
|
if cmd_class:
|
||||||
where.append("cmd_class = :cls")
|
where.append("cmd_class = :cls")
|
||||||
p["cls"] = cmd_class
|
p["cls"] = cmd_class
|
||||||
|
if not include_archived:
|
||||||
|
where.append("archived_at IS NULL")
|
||||||
sql = "SELECT * FROM pms_command"
|
sql = "SELECT * FROM pms_command"
|
||||||
if where:
|
if where:
|
||||||
sql += " WHERE " + " AND ".join(where)
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
|
@ -351,7 +354,8 @@ def insert_instruction(*, instruction_id, origin_type, origin_id, ts_code, actio
|
||||||
"w": int(window_tdays), "st": status, "pg": _dumps(progress), "ts": now})
|
"w": int(window_tdays), "st": status, "pg": _dumps(progress), "ts": now})
|
||||||
|
|
||||||
|
|
||||||
def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 300) -> list:
|
def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 300,
|
||||||
|
include_archived: bool = False) -> list:
|
||||||
where, p = [], {"n": int(limit)}
|
where, p = [], {"n": int(limit)}
|
||||||
if statuses:
|
if statuses:
|
||||||
keys = []
|
keys = []
|
||||||
|
|
@ -365,6 +369,8 @@ def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 30
|
||||||
if ts_code:
|
if ts_code:
|
||||||
where.append("ts_code = :code")
|
where.append("ts_code = :code")
|
||||||
p["code"] = ts_code
|
p["code"] = ts_code
|
||||||
|
if not include_archived:
|
||||||
|
where.append("archived_at IS NULL")
|
||||||
sql = "SELECT * FROM pms_instruction"
|
sql = "SELECT * FROM pms_instruction"
|
||||||
if where:
|
if where:
|
||||||
sql += " WHERE " + " AND ".join(where)
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
|
@ -422,14 +428,19 @@ def insert_proposal(*, proposal_id, ts_code, action, qty, hard_numbers, expire_a
|
||||||
"st": status, "exp": expire_at, "ts": _NOW()})
|
"st": status, "exp": expire_at, "ts": _NOW()})
|
||||||
|
|
||||||
|
|
||||||
def list_proposals(*, statuses=("WAIT_USER",), limit: int = 200) -> list:
|
def list_proposals(*, statuses=("WAIT_USER",), limit: int = 200,
|
||||||
p, keys = {"n": int(limit)}, []
|
include_archived: bool = False) -> list:
|
||||||
|
where, p, keys = [], {"n": int(limit)}, []
|
||||||
for i, s in enumerate(statuses or ()):
|
for i, s in enumerate(statuses or ()):
|
||||||
keys.append(f":s{i}")
|
keys.append(f":s{i}")
|
||||||
p[f"s{i}"] = s
|
p[f"s{i}"] = s
|
||||||
sql = "SELECT * FROM pms_proposal"
|
|
||||||
if keys:
|
if keys:
|
||||||
sql += f" WHERE status IN ({', '.join(keys)})"
|
where.append(f"status IN ({', '.join(keys)})")
|
||||||
|
if not include_archived:
|
||||||
|
where.append("archived_at IS NULL")
|
||||||
|
sql = "SELECT * FROM pms_proposal"
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
sql += " ORDER BY id DESC LIMIT :n"
|
sql += " ORDER BY id DESC LIMIT :n"
|
||||||
rows = fetch_all(sql, p)
|
rows = fetch_all(sql, p)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|
@ -737,7 +748,8 @@ def _snapshot_row(r: dict) -> dict:
|
||||||
STRATEGY_COLS = {"status", "autonomy", "params_json", "state_json", "note"}
|
STRATEGY_COLS = {"status", "autonomy", "params_json", "state_json", "note"}
|
||||||
|
|
||||||
|
|
||||||
def list_strategies(*, ts_code=None, statuses=None, limit: int = 500) -> list:
|
def list_strategies(*, ts_code=None, statuses=None, limit: int = 500,
|
||||||
|
include_archived: bool = False) -> list:
|
||||||
where, p = [], {"n": int(limit)}
|
where, p = [], {"n": int(limit)}
|
||||||
if ts_code:
|
if ts_code:
|
||||||
where.append("ts_code = :code")
|
where.append("ts_code = :code")
|
||||||
|
|
@ -748,6 +760,8 @@ def list_strategies(*, ts_code=None, statuses=None, limit: int = 500) -> list:
|
||||||
keys.append(f":st{i}")
|
keys.append(f":st{i}")
|
||||||
p[f"st{i}"] = s
|
p[f"st{i}"] = s
|
||||||
where.append(f"status IN ({', '.join(keys)})")
|
where.append(f"status IN ({', '.join(keys)})")
|
||||||
|
if not include_archived:
|
||||||
|
where.append("archived_at IS NULL")
|
||||||
sql = "SELECT * FROM pms_strategy"
|
sql = "SELECT * FROM pms_strategy"
|
||||||
if where:
|
if where:
|
||||||
sql += " WHERE " + " AND ".join(where)
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
|
@ -805,6 +819,65 @@ def update_strategy(strategy_id: str, **fields) -> int:
|
||||||
f"UPDATE pms_strategy SET {clause}, updated_at = :ts WHERE strategy_id = :sid", p)
|
f"UPDATE pms_strategy SET {clause}, updated_at = :ts WHERE strategy_id = :sid", p)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ 软归档 (从在办/在途视图移除)
|
||||||
|
# 只标记 archived_at, **绝不删行** —— 评审账本、批次、成交链、审计都还在库里。
|
||||||
|
# archive_* 只对**终态**记录生效 (status 在终态集内 且 archived_at 仍为空), 返回影响行数
|
||||||
|
# (0 = 该记录不是终态、不存在、或早已归档, 属正常, 调用方据此提示); unarchive_* 清掉标记、
|
||||||
|
# 恢复到默认视图, 不限状态。各表的 list_* 默认 include_archived=False, 已归档的自动不出现。
|
||||||
|
_ARCHIVE_TERMINAL = {
|
||||||
|
"pms_command": ("DONE", "CANCELLED", "SUPERSEDED"),
|
||||||
|
"pms_strategy": ("CANCELLED", "DONE"),
|
||||||
|
"pms_instruction": ("CONFIRMED", "EXPIRED", "CANCELLED", "REJECTED"),
|
||||||
|
"pms_proposal": ("ACCEPTED", "DECLINED", "EXPIRED"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _archive(table: str, id_col: str, rid: str) -> int:
|
||||||
|
vals = _ARCHIVE_TERMINAL[table] # table 是本模块写死的常量, 非外部输入
|
||||||
|
keys = ", ".join(f":st{i}" for i in range(len(vals)))
|
||||||
|
p = {"rid": rid, "ts": _NOW()}
|
||||||
|
p.update({f"st{i}": v for i, v in enumerate(vals)})
|
||||||
|
return execute(
|
||||||
|
f"UPDATE {table} SET archived_at = :ts WHERE {id_col} = :rid "
|
||||||
|
f"AND archived_at IS NULL AND status IN ({keys})", p)
|
||||||
|
|
||||||
|
|
||||||
|
def _unarchive(table: str, id_col: str, rid: str) -> int:
|
||||||
|
return execute(f"UPDATE {table} SET archived_at = NULL WHERE {id_col} = :rid", {"rid": rid})
|
||||||
|
|
||||||
|
|
||||||
|
def archive_command(command_id: str) -> int:
|
||||||
|
return _archive("pms_command", "command_id", command_id)
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_command(command_id: str) -> int:
|
||||||
|
return _unarchive("pms_command", "command_id", command_id)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_instruction(instruction_id: str) -> int:
|
||||||
|
return _archive("pms_instruction", "instruction_id", instruction_id)
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_instruction(instruction_id: str) -> int:
|
||||||
|
return _unarchive("pms_instruction", "instruction_id", instruction_id)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_proposal(proposal_id: str) -> int:
|
||||||
|
return _archive("pms_proposal", "proposal_id", proposal_id)
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_proposal(proposal_id: str) -> int:
|
||||||
|
return _unarchive("pms_proposal", "proposal_id", proposal_id)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_strategy(strategy_id: str) -> int:
|
||||||
|
return _archive("pms_strategy", "strategy_id", strategy_id)
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_strategy(strategy_id: str) -> int:
|
||||||
|
return _unarchive("pms_strategy", "strategy_id", strategy_id)
|
||||||
|
|
||||||
|
|
||||||
# ================================================================ pms_op_log (交易员操作日志)
|
# ================================================================ pms_op_log (交易员操作日志)
|
||||||
def insert_op_log(*, op, result, by="user", ts_code=None, reason=None, params=None, ref=None) -> int:
|
def insert_op_log(*, op, result, by="user", ts_code=None, reason=None, params=None, ref=None) -> int:
|
||||||
return execute(
|
return execute(
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,33 @@ def status() -> dict:
|
||||||
return {"available": True, "base": base_url(), "actions": sorted(judged_actions())}
|
return {"available": True, "base": base_url(), "actions": sorted(judged_actions())}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_verdict(data: dict) -> dict:
|
||||||
|
"""把决策系统应答的 verdict 归一到 PASS / REJECT / UNAVAILABLE, 并带出原因。
|
||||||
|
|
||||||
|
契约见 BIONIC_PMS_INTERFACE §2.3: verdict 合法取值是 PASS / REJECT / UNAVAILABLE,
|
||||||
|
UNAVAILABLE 还会带一句原因。UNAVAILABLE 是设计内的正常降级 (该股无昨夜结论、裁决
|
||||||
|
越界或解析失败、队列超时等), **必须把决策系统给的真实原因带出来**, 而不是把它当成
|
||||||
|
协议出错、拼一句「研判答复无法识别」盖掉真原因。早先没有这个分支时, 一条正常的
|
||||||
|
UNAVAILABLE 会落进最后那道兜底, 页面显示成「研判答复无法识别: UNAVAILABLE」——
|
||||||
|
读起来像协议 bug, 其实只是决策系统说「这只票我没有昨夜结论」。
|
||||||
|
只有既不是三种合法值、又解析不出的乱码, 才真算「无法识别」。
|
||||||
|
"""
|
||||||
|
data = data or {}
|
||||||
|
verdict = str(data.get("verdict") or data.get("decision") or "").upper()
|
||||||
|
reason = data.get("reason") or data.get("rationale") or ""
|
||||||
|
if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"):
|
||||||
|
return {"verdict": PASS, "reason": reason, "degraded": False, "raw": data}
|
||||||
|
if verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"):
|
||||||
|
return {"verdict": REJECT, "reason": reason, "degraded": False, "raw": data}
|
||||||
|
if verdict in ("UNAVAILABLE", "PMS_UNAVAILABLE", "NA", "N/A", "不可用"):
|
||||||
|
logger.warning("[研判闸] 决策系统回不可用, 降级人工确认: %s", reason or "(无原因)")
|
||||||
|
return {"verdict": UNAVAILABLE, "reason": reason or "决策系统研判不可用",
|
||||||
|
"degraded": True, "raw": data}
|
||||||
|
logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data)
|
||||||
|
return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}",
|
||||||
|
"degraded": True, "raw": data}
|
||||||
|
|
||||||
|
|
||||||
def request(candidate: dict, context: dict = None, *, timeout: int = None) -> dict:
|
def request(candidate: dict, context: dict = None, *, timeout: int = None) -> dict:
|
||||||
"""请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。"""
|
"""请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。"""
|
||||||
action = str(candidate.get("action") or "").upper()
|
action = str(candidate.get("action") or "").upper()
|
||||||
|
|
@ -147,14 +174,4 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di
|
||||||
return {"verdict": UNAVAILABLE, "reason": f"研判请求失败: {type(e).__name__}: {e}",
|
return {"verdict": UNAVAILABLE, "reason": f"研判请求失败: {type(e).__name__}: {e}",
|
||||||
"degraded": True, "raw": None}
|
"degraded": True, "raw": None}
|
||||||
|
|
||||||
verdict = str(data.get("verdict") or data.get("decision") or "").upper()
|
return _map_verdict(data)
|
||||||
if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"):
|
|
||||||
v = PASS
|
|
||||||
elif verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"):
|
|
||||||
v = REJECT
|
|
||||||
else:
|
|
||||||
logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data)
|
|
||||||
return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}",
|
|
||||||
"degraded": True, "raw": data}
|
|
||||||
return {"verdict": v, "reason": data.get("reason") or data.get("rationale") or "",
|
|
||||||
"degraded": False, "raw": data}
|
|
||||||
|
|
|
||||||
|
|
@ -181,9 +181,11 @@ def api_commands_active():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/commands")
|
@app.get("/api/commands")
|
||||||
def api_commands(status: str = Query(None), limit: int = Query(100)):
|
def api_commands(status: str = Query(None), limit: int = Query(100),
|
||||||
|
include_archived: bool = Query(False)):
|
||||||
statuses = [s for s in (status or "").split(",") if s] or None
|
statuses = [s for s in (status or "").split(",") if s] or None
|
||||||
return ok(pms_repo.list_commands, statuses=statuses, limit=limit)
|
return ok(pms_repo.list_commands, statuses=statuses, limit=limit,
|
||||||
|
include_archived=include_archived)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/commands/{command_id}")
|
@app.get("/api/commands/{command_id}")
|
||||||
|
|
@ -246,9 +248,11 @@ def api_lots(ts_code: str, status: str = Query("OPEN")):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/instructions")
|
@app.get("/api/instructions")
|
||||||
def api_instructions(status: str = Query(None), limit: int = 200):
|
def api_instructions(status: str = Query(None), limit: int = 200,
|
||||||
|
include_archived: bool = Query(False)):
|
||||||
statuses = [s for s in (status or "").split(",") if s] or None
|
statuses = [s for s in (status or "").split(",") if s] or None
|
||||||
return ok(pms_repo.list_instructions, statuses=statuses, limit=limit)
|
return ok(pms_repo.list_instructions, statuses=statuses, limit=limit,
|
||||||
|
include_archived=include_archived)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ledger")
|
@app.get("/api/ledger")
|
||||||
|
|
@ -264,9 +268,11 @@ def api_report(ymd: int = Query(None)):
|
||||||
|
|
||||||
# ================================================================ ④ 提议确认
|
# ================================================================ ④ 提议确认
|
||||||
@app.get("/api/proposals")
|
@app.get("/api/proposals")
|
||||||
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100):
|
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
|
||||||
|
include_archived: bool = Query(False)):
|
||||||
statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
|
statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
|
||||||
return ok(pms_repo.list_proposals, statuses=statuses, limit=limit)
|
return ok(pms_repo.list_proposals, statuses=statuses, limit=limit,
|
||||||
|
include_archived=include_archived)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/proposals/{proposal_id}/decide")
|
@app.post("/api/proposals/{proposal_id}/decide")
|
||||||
|
|
@ -636,12 +642,13 @@ def api_industry_import(payload: dict = Body(...)):
|
||||||
|
|
||||||
# ================================================================ 个股交易方案 (策略) + 操作日志
|
# ================================================================ 个股交易方案 (策略) + 操作日志
|
||||||
@app.get("/api/strategies")
|
@app.get("/api/strategies")
|
||||||
def api_strategies(status: str = Query(None)):
|
def api_strategies(status: str = Query(None), include_archived: bool = Query(False)):
|
||||||
statuses = [s for s in (status or "").split(",") if s] or None
|
statuses = [s for s in (status or "").split(",") if s] or None
|
||||||
from app.services import strategy_service
|
from app.services import strategy_service
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
rows = pms_repo.list_strategies(statuses=statuses, limit=300)
|
rows = pms_repo.list_strategies(statuses=statuses, limit=300,
|
||||||
|
include_archived=include_archived)
|
||||||
try:
|
try:
|
||||||
bp = strategy_service.buypause_map()
|
bp = strategy_service.buypause_map()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -681,6 +688,52 @@ def api_strategy_status(strategy_id: str, payload: dict = Body(...)):
|
||||||
by=payload.get("by") or "user")
|
by=payload.get("by") or "user")
|
||||||
|
|
||||||
|
|
||||||
|
# ================================ 软归档: 把终态记录从在办/在途/待确认/策略列表移除 (不删行, 可恢复)
|
||||||
|
def _arch_result(n: int, on: bool) -> dict:
|
||||||
|
if on:
|
||||||
|
note = "已移除, 从视图隐藏 (行仍在库, 对账与审计不受影响)" if n > 0 \
|
||||||
|
else "未改动: 该记录不是终态、不存在、或早已移除"
|
||||||
|
else:
|
||||||
|
note = "已恢复显示" if n > 0 else "未改动: 不存在或本就未移除"
|
||||||
|
return {"ok": n > 0, "affected": n, "archived": on, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/commands/{command_id}/archive")
|
||||||
|
def api_archive_command(command_id: str, payload: dict = Body(default={})):
|
||||||
|
on = bool(payload.get("archived", True))
|
||||||
|
return ok_logged("archive_command" if on else "unarchive_command",
|
||||||
|
lambda: _arch_result(
|
||||||
|
(pms_repo.archive_command if on else pms_repo.unarchive_command)(command_id), on),
|
||||||
|
params={"command_id": command_id, "archived": on})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/instructions/{instruction_id}/archive")
|
||||||
|
def api_archive_instruction(instruction_id: str, payload: dict = Body(default={})):
|
||||||
|
on = bool(payload.get("archived", True))
|
||||||
|
return ok_logged("archive_instruction" if on else "unarchive_instruction",
|
||||||
|
lambda: _arch_result(
|
||||||
|
(pms_repo.archive_instruction if on else pms_repo.unarchive_instruction)(instruction_id), on),
|
||||||
|
params={"instruction_id": instruction_id, "archived": on})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/proposals/{proposal_id}/archive")
|
||||||
|
def api_archive_proposal(proposal_id: str, payload: dict = Body(default={})):
|
||||||
|
on = bool(payload.get("archived", True))
|
||||||
|
return ok_logged("archive_proposal" if on else "unarchive_proposal",
|
||||||
|
lambda: _arch_result(
|
||||||
|
(pms_repo.archive_proposal if on else pms_repo.unarchive_proposal)(proposal_id), on),
|
||||||
|
params={"proposal_id": proposal_id, "archived": on})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/strategies/{strategy_id}/archive")
|
||||||
|
def api_archive_strategy(strategy_id: str, payload: dict = Body(default={})):
|
||||||
|
on = bool(payload.get("archived", True))
|
||||||
|
return ok_logged("archive_strategy" if on else "unarchive_strategy",
|
||||||
|
lambda: _arch_result(
|
||||||
|
(pms_repo.archive_strategy if on else pms_repo.unarchive_strategy)(strategy_id), on),
|
||||||
|
params={"strategy_id": strategy_id, "archived": on})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/op-log")
|
@app.get("/api/op-log")
|
||||||
def api_op_log(limit: int = Query(200)):
|
def api_op_log(limit: int = Query(200)):
|
||||||
"""交易员操作日志 (每个页面写操作一行, 含 OK/BLOCKED 与原因)。"""
|
"""交易员操作日志 (每个页面写操作一行, 含 OK/BLOCKED 与原因)。"""
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ body{margin:0;background:var(--plane);color:var(--ink);
|
||||||
.disp.deny{background:var(--stop-weak);color:var(--stop);}
|
.disp.deny{background:var(--stop-weak);color:var(--stop);}
|
||||||
.disp.would{background:var(--surface);color:var(--accent);border:1px solid var(--accent);}
|
.disp.would{background:var(--surface);color:var(--accent);border:1px solid var(--accent);}
|
||||||
.disp.wait{background:rgba(138,136,130,.14);color:var(--ink-2);}
|
.disp.wait{background:rgba(138,136,130,.14);color:var(--ink-2);}
|
||||||
|
.disp.strong{background:var(--accent-weak);color:var(--accent);}
|
||||||
.why{color:var(--ink-2);font-size:12.5px;margin-top:3px;line-height:1.45;}
|
.why{color:var(--ink-2);font-size:12.5px;margin-top:3px;line-height:1.45;}
|
||||||
.pool-note{margin:0 0 12px;padding:9px 12px;background:var(--surface-2);border:1px solid var(--hair);border-radius:9px;color:var(--ink-2);font-size:12.5px;}
|
.pool-note{margin:0 0 12px;padding:9px 12px;background:var(--surface-2);border:1px solid var(--hair);border-radius:9px;color:var(--ink-2);font-size:12.5px;}
|
||||||
.tabn{color:var(--muted);font-weight:600;font-size:12px;margin-left:3px;}
|
.tabn{color:var(--muted);font-weight:600;font-size:12px;margin-left:3px;}
|
||||||
|
|
@ -158,7 +159,7 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
.alert-bar .ab-toggle{flex:none;color:var(--accent);font-size:12.5px;font-weight:560;cursor:pointer;background:none;border:0;padding:4px 4px;}
|
.alert-bar .ab-toggle{flex:none;color:var(--accent);font-size:12.5px;font-weight:560;cursor:pointer;background:none;border:0;padding:4px 4px;}
|
||||||
.alert-bar .ab-empty{flex:1;color:var(--muted);font-size:12.5px;}
|
.alert-bar .ab-empty{flex:1;color:var(--muted);font-size:12.5px;}
|
||||||
.alert-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--muted);}
|
.alert-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--muted);}
|
||||||
.alert-dot.lv-danger{background:var(--stop);} .alert-dot.lv-warn{background:var(--warn);}
|
.alert-dot.lv-danger{background:var(--stop);} .alert-dot.lv-warn{background:var(--warn);} .alert-dot.lv-strong{background:var(--accent);}
|
||||||
.alert-panel{margin-top:0;}
|
.alert-panel{margin-top:0;}
|
||||||
.alert-wins{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:14px;margin:2px 0 16px;}
|
.alert-wins{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:14px;margin:2px 0 16px;}
|
||||||
.alert-win{border:1px solid var(--hair);border-radius:12px;padding:10px 14px 12px;background:var(--surface);box-shadow:var(--shadow);}
|
.alert-win{border:1px solid var(--hair);border-radius:12px;padding:10px 14px 12px;background:var(--surface);box-shadow:var(--shadow);}
|
||||||
|
|
@ -173,7 +174,7 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
.alert-age{color:var(--muted);font-size:10.5px;margin-left:5px;}
|
.alert-age{color:var(--muted);font-size:10.5px;margin-left:5px;}
|
||||||
/* 上游信号中心: 最新告警细提示 + 级别筛选 chip + 分节标题 */
|
/* 上游信号中心: 最新告警细提示 + 级别筛选 chip + 分节标题 */
|
||||||
.sig-ticker{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--hair);border-left:3px solid var(--hair);border-radius:12px;padding:8px 14px;margin:16px 0 8px;box-shadow:var(--shadow);font-size:13px;}
|
.sig-ticker{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--hair);border-left:3px solid var(--hair);border-radius:12px;padding:8px 14px;margin:16px 0 8px;box-shadow:var(--shadow);font-size:13px;}
|
||||||
.sig-ticker.lv-danger{border-left-color:var(--stop);} .sig-ticker.lv-warn{border-left-color:var(--warn);}
|
.sig-ticker.lv-danger{border-left-color:var(--stop);} .sig-ticker.lv-warn{border-left-color:var(--warn);} .sig-ticker.lv-strong{border-left-color:var(--accent);}
|
||||||
.sig-ticker .sig-tk-label{font-weight:640;color:var(--ink-2);white-space:nowrap;}
|
.sig-ticker .sig-tk-label{font-weight:640;color:var(--ink-2);white-space:nowrap;}
|
||||||
.sig-filter{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin:2px 0 12px;}
|
.sig-filter{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin:2px 0 12px;}
|
||||||
.lv-chip{font-size:12px;font-weight:560;padding:2px 11px;border-radius:999px;cursor:pointer;border:1px solid var(--hair);user-select:none;color:var(--ink-2);background:var(--surface-2);}
|
.lv-chip{font-size:12px;font-weight:560;padding:2px 11px;border-radius:999px;cursor:pointer;border:1px solid var(--hair);user-select:none;color:var(--ink-2);background:var(--surface-2);}
|
||||||
|
|
@ -181,6 +182,7 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
.lv-chip.on.lv-danger{color:var(--stop);background:var(--stop-weak);border-color:var(--stop-weak);}
|
.lv-chip.on.lv-danger{color:var(--stop);background:var(--stop-weak);border-color:var(--stop-weak);}
|
||||||
.lv-chip.on.lv-warn{color:var(--warn);background:var(--warn-weak);border-color:var(--warn-weak);}
|
.lv-chip.on.lv-warn{color:var(--warn);background:var(--warn-weak);border-color:var(--warn-weak);}
|
||||||
.lv-chip.on.lv-info{color:var(--ink-2);background:rgba(138,136,130,.14);}
|
.lv-chip.on.lv-info{color:var(--ink-2);background:rgba(138,136,130,.14);}
|
||||||
|
.lv-chip.on.lv-strong{color:var(--accent);background:var(--accent-weak);border-color:var(--accent-weak);}
|
||||||
.sig-sep{font-size:12px;color:var(--muted);font-weight:600;margin:16px 0 8px;padding-top:9px;border-top:1px dashed var(--hair);}
|
.sig-sep{font-size:12px;color:var(--muted);font-weight:600;margin:16px 0 8px;padding-top:9px;border-top:1px dashed var(--hair);}
|
||||||
/* 宏观择时面板 (MACRO_TIMING_PLAN.md) */
|
/* 宏观择时面板 (MACRO_TIMING_PLAN.md) */
|
||||||
.mz-bars{display:flex;align-items:flex-end;gap:2px;height:34px;margin:7px 0 3px;}
|
.mz-bars{display:flex;align-items:flex-end;gap:2px;height:34px;margin:7px 0 3px;}
|
||||||
|
|
@ -646,11 +648,12 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
<h3>我的策略 · {{ strategies.length }} 个
|
<h3>我的策略 · {{ strategies.length }} 个
|
||||||
<el-tag size="small" :type="stratEnabled?'primary':'info'" effect="dark" style="margin-left:8px">
|
<el-tag size="small" :type="stratEnabled?'primary':'info'" effect="dark" style="margin-left:8px">
|
||||||
{{ stratEnabled ? '策略层已启用' : '策略层总开关未开(PMS_STRATEGY_ENABLED)' }}</el-tag>
|
{{ stratEnabled ? '策略层已启用' : '策略层总开关未开(PMS_STRATEGY_ENABLED)' }}</el-tag>
|
||||||
|
<el-checkbox v-model="showAllStrategies" @change="loadStrategies" size="small" style="margin-left:12px;font-weight:normal">显示已撤下/已完成</el-checkbox>
|
||||||
</h3>
|
</h3>
|
||||||
<el-alert v-if="!stratEnabled" type="info" effect="dark" :closable="false" show-icon style="margin-bottom:8px"
|
<el-alert v-if="!stratEnabled" type="info" effect="dark" :closable="false" show-icon style="margin-bottom:8px"
|
||||||
title="策略挂了也不会出手:请在「设置 / 运维」把 PMS_STRATEGY_ENABLED 打开;影子还是实盘由 PMS_DISPATCH_MODE 决定。"></el-alert>
|
title="策略挂了也不会出手:请在「设置 / 运维」把 PMS_STRATEGY_ENABLED 打开;影子还是实盘由 PMS_DISPATCH_MODE 决定。"></el-alert>
|
||||||
<div class="muted" v-if="!strategies.length">还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。</div>
|
<div class="muted" v-if="!strategies.length">还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。</div>
|
||||||
<el-table v-else :data="strategies" size="small" border max-height="300">
|
<el-table v-else :data="strategiesShown" size="small" border max-height="300" empty-text="没有进行中的策略(勾选『显示已撤下/已完成』看全部)">
|
||||||
<el-table-column label="股票" width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
|
<el-table-column label="股票" width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
|
||||||
<el-table-column label="类型" width="86"><template #default="s"><el-tag size="small" effect="dark">{{ tx('stype', s.row.type) }}</el-tag></template></el-table-column>
|
<el-table-column label="类型" width="86"><template #default="s"><el-tag size="small" effect="dark">{{ tx('stype', s.row.type) }}</el-tag></template></el-table-column>
|
||||||
<el-table-column label="自主档" width="86"><template #default="s">{{ s.row.autonomy==='auto'?'自动执行':'待我拍板' }}</template></el-table-column>
|
<el-table-column label="自主档" width="86"><template #default="s">{{ s.row.autonomy==='auto'?'自动执行':'待我拍板' }}</template></el-table-column>
|
||||||
|
|
@ -659,11 +662,13 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
<div v-if="s.row.buy_paused" style="color:#9a6a00;font-size:11px">决策系统风控:{{ s.row.buy_paused.reason || '资金异动' }}(卖出/平回不受影响;确认无碍点右侧「恢复买入」)</div>
|
<div v-if="s.row.buy_paused" style="color:#9a6a00;font-size:11px">决策系统风控:{{ s.row.buy_paused.reason || '资金异动' }}(卖出/平回不受影响;确认无碍点右侧「恢复买入」)</div>
|
||||||
</template></el-table-column>
|
</template></el-table-column>
|
||||||
<el-table-column label="状态" width="84"><template #default="s"><el-tag size="small" :type="s.row.status==='ACTIVE'?'primary':'info'">{{ tx('stratStatus', s.row.status) }}</el-tag></template></el-table-column>
|
<el-table-column label="状态" width="84"><template #default="s"><el-tag size="small" :type="s.row.status==='ACTIVE'?'primary':'info'">{{ tx('stratStatus', s.row.status) }}</el-tag></template></el-table-column>
|
||||||
<el-table-column label="操作" width="260"><template #default="s">
|
<el-table-column label="操作" width="300"><template #default="s">
|
||||||
<el-button v-if="s.row.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.row.strategy_id,'PAUSED')">暂停</el-button>
|
<el-button v-if="s.row.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.row.strategy_id,'PAUSED')">暂停</el-button>
|
||||||
<el-button v-else-if="s.row.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.row.strategy_id,'ACTIVE')">恢复</el-button>
|
<el-button v-else-if="s.row.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.row.strategy_id,'ACTIVE')">恢复</el-button>
|
||||||
<el-button v-if="s.row.buy_paused" size="small" type="warning" @click="resumeBuy(s.row)">恢复买入</el-button>
|
<el-button v-if="s.row.buy_paused" size="small" type="warning" @click="resumeBuy(s.row)">恢复买入</el-button>
|
||||||
<el-button size="small" type="danger" @click="setStrategyStatus(s.row.strategy_id,'CANCELLED')">撤下</el-button>
|
<el-button v-if="!stratArchivable(s.row)" size="small" type="danger" @click="setStrategyStatus(s.row.strategy_id,'CANCELLED')">撤下</el-button>
|
||||||
|
<el-button v-if="stratArchivable(s.row) && !s.row.archived_at" size="small" @click="archiveRow('strategies', s.row.strategy_id, true)">移除</el-button>
|
||||||
|
<el-button v-if="s.row.archived_at" size="small" type="info" @click="archiveRow('strategies', s.row.strategy_id, false)">恢复显示</el-button>
|
||||||
</template></el-table-column>
|
</template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
|
@ -970,8 +975,10 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>在途命令与进度</h3>
|
<h3>在途命令与进度
|
||||||
<el-table :data="commands" size="small" border max-height="420"
|
<el-checkbox v-model="showAllCommands" @change="loadCommands" size="small" style="margin-left:12px;font-weight:normal">显示已完成/已移除</el-checkbox>
|
||||||
|
</h3>
|
||||||
|
<el-table :data="commandsShown" size="small" border max-height="420"
|
||||||
:row-class-name="()=>''">
|
:row-class-name="()=>''">
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="s">
|
<template #default="s">
|
||||||
|
|
@ -1017,12 +1024,16 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="issued_at" label="下达时间" width="160" class-name="muted"></el-table-column>
|
<el-table-column prop="issued_at" label="下达时间" width="160" class-name="muted"></el-table-column>
|
||||||
<el-table-column label="操作" width="150">
|
<el-table-column label="操作" width="220">
|
||||||
<template #default="s">
|
<template #default="s">
|
||||||
<el-button size="small" :disabled="!canCancel(s.row)"
|
<el-button size="small" :disabled="!canCancel(s.row)"
|
||||||
@click="cancelCmd(s.row.command_id)">撤销</el-button>
|
@click="cancelCmd(s.row.command_id)">撤销</el-button>
|
||||||
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
|
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
|
||||||
@click="replan(s.row.command_id)">重规划</el-button>
|
@click="replan(s.row.command_id)">重规划</el-button>
|
||||||
|
<el-button v-if="cmdArchivable(s.row) && !s.row.archived_at" size="small"
|
||||||
|
@click="archiveRow('commands', s.row.command_id, true)">移除</el-button>
|
||||||
|
<el-button v-if="s.row.archived_at" size="small" type="info"
|
||||||
|
@click="archiveRow('commands', s.row.command_id, false)">恢复显示</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
@ -1096,8 +1107,10 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>在途指令</h3>
|
<h3>在途指令
|
||||||
<el-table :data="instructions" size="small" border max-height="320">
|
<el-checkbox v-model="showAllInstr" size="small" style="margin-left:12px;font-weight:normal">显示已了结</el-checkbox>
|
||||||
|
</h3>
|
||||||
|
<el-table :data="instrLive" size="small" border max-height="320" empty-text="没有在途指令">
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="s">
|
<template #default="s">
|
||||||
<div style="padding:6px 16px">
|
<div style="padding:6px 16px">
|
||||||
|
|
@ -1558,6 +1571,9 @@ createApp({
|
||||||
const positions = ref([]), lots = ref([]), lotsOf = ref('');
|
const positions = ref([]), lots = ref([]), lotsOf = ref('');
|
||||||
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
|
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
|
||||||
const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]);
|
const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]);
|
||||||
|
// 「显示已完成/已移除」开关 (默认关: 只看进行中)。命令与策略勾选时会拉全量含已归档 (loadX 传
|
||||||
|
// include_archived); 在途指令纯前端过滤, 不重拉。
|
||||||
|
const showAllCommands = ref(false), showAllStrategies = ref(false), showAllInstr = ref(false);
|
||||||
const showLedger = ref(false); // 「今天发生了什么」默认折叠
|
const showLedger = ref(false); // 「今天发生了什么」默认折叠
|
||||||
const showOpLog = ref(false); // 操作日志默认折叠
|
const showOpLog = ref(false); // 操作日志默认折叠
|
||||||
const showSettings = ref(false); // 设置默认折叠
|
const showSettings = ref(false); // 设置默认折叠
|
||||||
|
|
@ -1569,7 +1585,7 @@ createApp({
|
||||||
const alertShowMap = reactive({ money_flow:true, capital:true, qrs:true, volume:true, multi:true, price_notice:false, buy_emitted:false, other:true });
|
const alertShowMap = reactive({ money_flow:true, capital:true, qrs:true, volume:true, multi:true, price_notice:false, buy_emitted:false, other:true });
|
||||||
// 级别筛选记忆: 存 sessionStorage —— 普通刷新保留, 关标签页/新开标签=回默认。默认关 普通/提示。
|
// 级别筛选记忆: 存 sessionStorage —— 普通刷新保留, 关标签页/新开标签=回默认。默认关 普通/提示。
|
||||||
const LV_STORE_KEY = 'pms_level_show';
|
const LV_STORE_KEY = 'pms_level_show';
|
||||||
const _lvDefault = { DANGER:true, WARNING:true, IMPORTANT:true, INFO:false, NORMAL:false };
|
const _lvDefault = { DANGER:true, WARNING:true, INFO:false, NORMAL:false };
|
||||||
const _lvLoad = () => { try { const v = JSON.parse(sessionStorage.getItem(LV_STORE_KEY)); if (v && typeof v === 'object') return { ..._lvDefault, ...v }; } catch (e) {} return { ..._lvDefault }; };
|
const _lvLoad = () => { try { const v = JSON.parse(sessionStorage.getItem(LV_STORE_KEY)); if (v && typeof v === 'object') return { ..._lvDefault, ...v }; } catch (e) {} return { ..._lvDefault }; };
|
||||||
const levelShow = reactive(_lvLoad());
|
const levelShow = reactive(_lvLoad());
|
||||||
const openScan = ref({ by_code: {}, notes: [] });
|
const openScan = ref({ by_code: {}, notes: [] });
|
||||||
|
|
@ -1754,11 +1770,25 @@ createApp({
|
||||||
const marketState = computed(() =>
|
const marketState = computed(() =>
|
||||||
!health.value.trade_day ? '非交易日' : (marketOpen.value ? '盘中' : '休市'));
|
!health.value.trade_day ? '非交易日' : (marketOpen.value ? '盘中' : '休市'));
|
||||||
const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'];
|
const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'];
|
||||||
|
const _isArch = r => !!(r && r.archived_at); // 软归档: 有 archived_at = 已从视图移除 (行仍在库)
|
||||||
const _sameDayIns = r => { const y = todayYmd.value;
|
const _sameDayIns = r => { const y = todayYmd.value;
|
||||||
return !y || String(r.instruction_id || '').indexOf('INS_' + y + '_') === 0; };
|
return !y || String(r.instruction_id || '').indexOf('INS_' + y + '_') === 0; };
|
||||||
const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status)));
|
// 已归档的一律不进「今日在办」三档 —— 归档=从所有面板隐藏。
|
||||||
const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' && _sameDayIns(r)));
|
const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status) && !_isArch(r)));
|
||||||
const insEnd = computed(() => (instructions.value || []).filter(r => ['EXPIRED', 'CANCELLED', 'REJECTED'].includes(r.status) && _sameDayIns(r)));
|
const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' && _sameDayIns(r) && !_isArch(r)));
|
||||||
|
const insEnd = computed(() => (instructions.value || []).filter(r => ['EXPIRED', 'CANCELLED', 'REJECTED'].includes(r.status) && _sameDayIns(r) && !_isArch(r)));
|
||||||
|
// 在办/在途/策略列表: 默认只看进行中; 「显示已完成」开关放开终态。命令与策略开关还会拉出已归档 (带恢复按钮)。
|
||||||
|
const _CMD_ACTIVE = ['PENDING', 'PLANNING', 'EXECUTING', 'PARTIAL', 'EFFECTIVE'];
|
||||||
|
const _STRAT_ACTIVE = ['ACTIVE', 'PAUSED'];
|
||||||
|
const commandsShown = computed(() => (commands.value || []).filter(
|
||||||
|
r => showAllCommands.value ? true : (_CMD_ACTIVE.includes(r.status) && !_isArch(r))));
|
||||||
|
const strategiesShown = computed(() => (strategies.value || []).filter(
|
||||||
|
r => showAllStrategies.value ? true : (_STRAT_ACTIVE.includes(r.status) && !_isArch(r))));
|
||||||
|
const instrLive = computed(() => (instructions.value || []).filter(
|
||||||
|
r => showAllInstr.value ? true : (_LIVE_INS.includes(r.status) && !_isArch(r))));
|
||||||
|
// 哪些行可「移除」(只终态): 与后端 archive_* 的终态集一致。
|
||||||
|
const cmdArchivable = r => ['DONE', 'CANCELLED', 'SUPERSEDED'].includes(r.status);
|
||||||
|
const stratArchivable = r => ['CANCELLED', 'DONE'].includes(r.status);
|
||||||
// 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。
|
// 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。
|
||||||
function dispOf(code) {
|
function dispOf(code) {
|
||||||
const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN');
|
const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN');
|
||||||
|
|
@ -1992,7 +2022,8 @@ createApp({
|
||||||
catalog.value = ((d.data || d).commands) || [];
|
catalog.value = ((d.data || d).commands) || [];
|
||||||
}
|
}
|
||||||
async function loadCommands() {
|
async function loadCommands() {
|
||||||
const d = await call('get', '/api/commands?limit=60'); commands.value = d.data || [];
|
const q = '/api/commands?limit=60' + (showAllCommands.value ? '&include_archived=1' : '');
|
||||||
|
const d = await call('get', q); commands.value = d.data || [];
|
||||||
}
|
}
|
||||||
async function loadPositions() {
|
async function loadPositions() {
|
||||||
const d = await call('get', '/api/positions'); positions.value = d.positions || [];
|
const d = await call('get', '/api/positions'); positions.value = d.positions || [];
|
||||||
|
|
@ -2014,20 +2045,24 @@ createApp({
|
||||||
}
|
}
|
||||||
const usrc = k => (upstream.value.sources || {})[k];
|
const usrc = k => (upstream.value.sources || {})[k];
|
||||||
const alertItems = computed(() => { const a = usrc('alerts'); return (a && Array.isArray(a.items)) ? a.items : []; });
|
const alertItems = computed(() => { const a = usrc('alerts'); return (a && Array.isArray(a.items)) ? a.items : []; });
|
||||||
// 级别筛选 (纯前端): 已知级别按开关过滤; 未知级别一律放行 (风控从宽, 不误杀)。
|
// 级别筛选 (纯前端): 级别是「信号强度分档」不是方向。DANGER 是最强、最该看的那一档,
|
||||||
const KNOWN_LV = new Set(['DANGER','WARNING','IMPORTANT','INFO','NORMAL']);
|
// 决策系统甚至把它转述成「极值偏离」; 盘中择时层与决策系统两套来源口径一致。原来显示成
|
||||||
|
// 「危险」是误译 —— 一条 DANGER 级看涨信号其实是很强的买入信号, 标红会被误读成会亏。
|
||||||
|
// 2026-08-25: DANGER 显示为「重要」并用蓝色强调色; 空的 IMPORTANT 并入重要档 (真实数据
|
||||||
|
// 里两套系统都不产出 IMPORTANT, 它是永远匹配不到的空档)。已知级别按开关过滤, 未知放行。
|
||||||
|
const normLv = v => { const x = String(v == null ? '' : v); return x === 'IMPORTANT' ? 'DANGER' : x; };
|
||||||
|
const KNOWN_LV = new Set(['DANGER','WARNING','INFO','NORMAL']);
|
||||||
const levelShown = k => levelShow[k] !== false;
|
const levelShown = k => levelShow[k] !== false;
|
||||||
function toggleLevel(k){ levelShow[k] = !levelShown(k); try { sessionStorage.setItem(LV_STORE_KEY, JSON.stringify(levelShow)); } catch (e) {} }
|
function toggleLevel(k){ levelShow[k] = !levelShown(k); try { sessionStorage.setItem(LV_STORE_KEY, JSON.stringify(levelShow)); } catch (e) {} }
|
||||||
const passLevel = lv => { const x = String(lv == null ? '' : lv); return !KNOWN_LV.has(x) || levelShow[x] !== false; };
|
const passLevel = lv => { const x = normLv(lv); return !KNOWN_LV.has(x) || levelShow[x] !== false; };
|
||||||
const alertItemsF = computed(() => alertItems.value.filter(a => passLevel(a.level)));
|
const alertItemsF = computed(() => alertItems.value.filter(a => passLevel(a.level)));
|
||||||
const levelCount = k => alertItems.value.reduce((n, a) => n + (String(a.level) === k ? 1 : 0), 0);
|
const levelCount = k => alertItems.value.reduce((n, a) => n + (normLv(a.level) === k ? 1 : 0), 0);
|
||||||
const ALERT_LEVELS = [
|
const ALERT_LEVELS = [
|
||||||
{ key:'DANGER', label:'危险', cls:'danger' }, { key:'WARNING', label:'警告', cls:'warn' },
|
{ key:'DANGER', label:'重要', cls:'strong' }, { key:'WARNING', label:'警告', cls:'warn' },
|
||||||
{ key:'IMPORTANT', label:'重要', cls:'warn' }, { key:'INFO', label:'提示', cls:'info' },
|
{ key:'INFO', label:'提示', cls:'info' }, { key:'NORMAL', label:'普通', cls:'info' }];
|
||||||
{ key:'NORMAL', label:'普通', cls:'info' }];
|
const LV_LABEL = { DANGER:'重要', WARNING:'警告', IMPORTANT:'重要', INFO:'提示', NORMAL:'普通' };
|
||||||
const LV_LABEL = { DANGER:'危险', WARNING:'警告', IMPORTANT:'重要', INFO:'提示', NORMAL:'普通' };
|
const lvLabel = v => LV_LABEL[normLv(v)] || v || '—';
|
||||||
const lvLabel = v => LV_LABEL[v] || v || '—';
|
const lvClass = v => { const x = normLv(v); return x === 'DANGER' ? 'strong' : (x === 'WARNING' ? 'queue' : 'wait'); };
|
||||||
const lvClass = v => v === 'DANGER' ? 'deny' : ((v === 'WARNING' || v === 'IMPORTANT') ? 'queue' : 'wait');
|
|
||||||
const flowText = d => d === 'inflow' ? '流入' : (d === 'outflow' ? '流出' : (d || '—'));
|
const flowText = d => d === 'inflow' ? '流入' : (d === 'outflow' ? '流出' : (d || '—'));
|
||||||
const flowClass = d => d === 'inflow' ? 'clr-up' : (d === 'outflow' ? 'clr-down' : 'muted');
|
const flowClass = d => d === 'inflow' ? 'clr-up' : (d === 'outflow' ? 'clr-down' : 'muted');
|
||||||
// ── 上游信号改版 (2026-08-18): 净额领读 + 强度带 + 按重要度分档 + 待触发条 ──
|
// ── 上游信号改版 (2026-08-18): 净额领读 + 强度带 + 按重要度分档 + 待触发条 ──
|
||||||
|
|
@ -2103,7 +2138,7 @@ createApp({
|
||||||
const alertTotalCount = computed(() => alertItems.value.length);
|
const alertTotalCount = computed(() => alertItems.value.length);
|
||||||
const alertLevelClass = computed(() => {
|
const alertLevelClass = computed(() => {
|
||||||
const it = alertStripItems.value;
|
const it = alertStripItems.value;
|
||||||
if (it.some(x => x.level === 'DANGER')) return 'danger';
|
if (it.some(x => normLv(x.level) === 'DANGER')) return 'strong';
|
||||||
if (it.some(x => x.level === 'WARNING')) return 'warn';
|
if (it.some(x => x.level === 'WARNING')) return 'warn';
|
||||||
return 'none';
|
return 'none';
|
||||||
});
|
});
|
||||||
|
|
@ -2297,10 +2332,20 @@ createApp({
|
||||||
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
|
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
|
||||||
}
|
}
|
||||||
async function loadStrategies() {
|
async function loadStrategies() {
|
||||||
const d = await call('get', '/api/strategies');
|
const q = '/api/strategies' + (showAllStrategies.value ? '?include_archived=1' : '');
|
||||||
|
const d = await call('get', q);
|
||||||
strategies.value = d.strategies || (d.data && d.data.strategies) || [];
|
strategies.value = d.strategies || (d.data && d.data.strategies) || [];
|
||||||
stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data && d.data.enabled));
|
stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data && d.data.enabled));
|
||||||
}
|
}
|
||||||
|
// 软归档: 把终态记录从在办视图移除 (on=true) 或恢复显示 (on=false)。kind = commands/strategies/instructions/proposals
|
||||||
|
async function archiveRow(kind, id, on) {
|
||||||
|
const d = await call('post', '/api/' + kind + '/' + id + '/archive', { archived: on });
|
||||||
|
if (d && d.ok) ElementPlus.ElMessage.success(on ? '已移除' : '已恢复显示');
|
||||||
|
else ElementPlus.ElMessage.info((d && (d.note || d.error)) || '未改动');
|
||||||
|
const reload = { commands: loadCommands, strategies: loadStrategies,
|
||||||
|
instructions: loadInstructions, proposals: loadProposals };
|
||||||
|
if (reload[kind]) await reload[kind]();
|
||||||
|
}
|
||||||
async function loadOpLog() {
|
async function loadOpLog() {
|
||||||
const d = await call('get', '/api/op-log?limit=80');
|
const d = await call('get', '/api/op-log?limit=80');
|
||||||
opLog.value = d.rows || (d.data && d.data.rows) || [];
|
opLog.value = d.rows || (d.data && d.data.rows) || [];
|
||||||
|
|
@ -2447,6 +2492,8 @@ createApp({
|
||||||
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
|
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
|
||||||
showLedger, onPosExpand,
|
showLedger, onPosExpand,
|
||||||
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
|
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
|
||||||
|
commandsShown, strategiesShown, instrLive, showAllCommands, showAllStrategies, showAllInstr,
|
||||||
|
cmdArchivable, stratArchivable, archiveRow,
|
||||||
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
|
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
|
||||||
upstream, showUpstream, usrc, loadUpstream,
|
upstream, showUpstream, usrc, loadUpstream,
|
||||||
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
|
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS pms_command (
|
||||||
issued_at DATETIME NOT NULL,
|
issued_at DATETIME NOT NULL,
|
||||||
done_at DATETIME NULL,
|
done_at DATETIME NULL,
|
||||||
note VARCHAR(500) NULL,
|
note VARCHAR(500) NULL,
|
||||||
|
archived_at DATETIME NULL COMMENT '软归档时间: 非空=已从在办视图移除, 行仍在库供审计; 只归档终态',
|
||||||
KEY idx_type_status (cmd_type, status),
|
KEY idx_type_status (cmd_type, status),
|
||||||
KEY idx_status (status)
|
KEY idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户命令与进度';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户命令与进度';
|
||||||
|
|
@ -126,6 +127,7 @@ CREATE TABLE IF NOT EXISTS pms_instruction (
|
||||||
progress_json TEXT NULL COMMENT '分日子单与出手记录',
|
progress_json TEXT NULL COMMENT '分日子单与出手记录',
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
|
archived_at DATETIME NULL COMMENT '软归档时间: 非空=已从在途/已了结视图移除, 行仍在库供对账; 只归档终态',
|
||||||
KEY idx_code_status (ts_code, status),
|
KEY idx_code_status (ts_code, status),
|
||||||
KEY idx_status (status)
|
KEY idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指令状态机';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指令状态机';
|
||||||
|
|
@ -145,6 +147,7 @@ CREATE TABLE IF NOT EXISTS pms_proposal (
|
||||||
expire_at DATETIME NOT NULL,
|
expire_at DATETIME NOT NULL,
|
||||||
decided_at DATETIME NULL,
|
decided_at DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
|
archived_at DATETIME NULL COMMENT '软归档时间: 非空=已从待确认视图移除, 行仍在库; 只归档终态',
|
||||||
KEY idx_status (status),
|
KEY idx_status (status),
|
||||||
KEY idx_code (ts_code)
|
KEY idx_code (ts_code)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自主提议待确认';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自主提议待确认';
|
||||||
|
|
@ -352,6 +355,7 @@ CREATE TABLE IF NOT EXISTS pms_strategy (
|
||||||
note VARCHAR(300) NULL,
|
note VARCHAR(300) NULL,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
|
archived_at DATETIME NULL COMMENT '软归档时间: 非空=已从策略列表移除, 行仍在库; 只归档已撤下/已完成',
|
||||||
KEY idx_code_status (ts_code, status),
|
KEY idx_code_status (ts_code, status),
|
||||||
KEY idx_status (status)
|
KEY idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个股交易方案 (策略)';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个股交易方案 (策略)';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
迁移: 给四张表加 archived_at 列 (软归档)
|
||||||
|
==========================================
|
||||||
|
运行:
|
||||||
|
docker compose run --rm pms-web python scripts/migrate_archived_at.py # 演练, 只打印不执行
|
||||||
|
docker compose run --rm pms-web python scripts/migrate_archived_at.py --yes # 实际加列
|
||||||
|
|
||||||
|
为什么单独一个脚本, 不放进 init_db:
|
||||||
|
init_db.py 只认 `CREATE TABLE IF NOT EXISTS` 与幂等 INSERT, 任何 ALTER 语句会被它判成
|
||||||
|
残句并整批中止。而 `CREATE TABLE IF NOT EXISTS` 对已存在的表一个字都不会改 —— 给旧表加列
|
||||||
|
只能靠 ALTER。所以: 已有库的 archived_at 列由本脚本补; 建表语句 (ddl_pms_v1.sql) 也已带上
|
||||||
|
该列, **新库直接就有**, 不需要跑这个脚本。
|
||||||
|
|
||||||
|
幂等: 已经有该列的表自动跳过, 重复跑安全。绕过 db.session 的单表守卫 (直接用引擎), 与 init_db 一致
|
||||||
|
—— ALTER 本身只涉及一张表, 但语句里的中文注释可能撞上守卫的关键字启发式, 没必要让它误伤。
|
||||||
|
若某条 ALTER 被 ShardingSphere-Proxy 拒绝, 脚本会把完整语句打出来, 直接拿去物理库执行即可。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
TABLES = ["pms_command", "pms_instruction", "pms_proposal", "pms_strategy"]
|
||||||
|
COL = "archived_at"
|
||||||
|
COMMENT = "软归档时间: 非空=已从在办视图移除, 行仍在库供审计; 只归档终态"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(conn, table: str, col: str) -> bool:
|
||||||
|
from sqlalchemy import text
|
||||||
|
rows = conn.execute(text(f"SHOW COLUMNS FROM {table}")).fetchall()
|
||||||
|
return any(r[0] == col for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--yes", action="store_true", help="确认执行 (缺省只演练)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.session import get_engine
|
||||||
|
eng = get_engine("proxy")
|
||||||
|
|
||||||
|
todo, already, unknown = [], [], []
|
||||||
|
with eng.connect() as c:
|
||||||
|
for t in TABLES:
|
||||||
|
try:
|
||||||
|
(already if _has_column(c, t, COL) else todo).append(t)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ? {t}: 查列失败 {type(e).__name__}: {e} (表不存在就先跑 init_db)")
|
||||||
|
unknown.append(t)
|
||||||
|
|
||||||
|
print(f"已有 {COL} 列 ({len(already)}): {', '.join(already) or '无'}")
|
||||||
|
print(f"待加 {COL} 列 ({len(todo)}): {', '.join(todo) or '无 —— 都已就位'}")
|
||||||
|
|
||||||
|
if not todo:
|
||||||
|
print("\n无需迁移: 四张表都已有该列 (或表还没建, 先 init_db)。")
|
||||||
|
sys.exit(1 if unknown else 0)
|
||||||
|
|
||||||
|
stmts = {t: (f"ALTER TABLE {t} ADD COLUMN {COL} DATETIME NULL DEFAULT NULL "
|
||||||
|
f"COMMENT '{COMMENT}'") for t in todo}
|
||||||
|
|
||||||
|
if not args.yes:
|
||||||
|
print("\n[演练模式] 未执行任何语句。确认无误后加 --yes 重跑。将执行:")
|
||||||
|
for t in todo:
|
||||||
|
print(" " + stmts[t])
|
||||||
|
return
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
print()
|
||||||
|
for t in todo:
|
||||||
|
try:
|
||||||
|
with eng.begin() as c:
|
||||||
|
c.execute(text(stmts[t]))
|
||||||
|
print(f" OK {t} 加列成功")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL {t}: {type(e).__name__}: {e}")
|
||||||
|
failed.append((t, stmts[t], str(e)))
|
||||||
|
|
||||||
|
print("\n[验证] 逐表确认列已在")
|
||||||
|
miss = []
|
||||||
|
with eng.connect() as c:
|
||||||
|
for t in todo:
|
||||||
|
try:
|
||||||
|
has = _has_column(c, t, COL)
|
||||||
|
print(f" {'OK ' if has else 'FAIL'} {t}.{COL} {'存在' if has else '仍缺'}")
|
||||||
|
if not has:
|
||||||
|
miss.append(t)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL {t}: {e}")
|
||||||
|
miss.append(t)
|
||||||
|
|
||||||
|
print("\n" + "-" * 62)
|
||||||
|
if failed:
|
||||||
|
print(f"以下 {len(failed)} 张表加列失败, 完整语句如下 —— 可直接拿到物理库 (my_quant_db) 执行:")
|
||||||
|
for t, s, err in failed:
|
||||||
|
print(f"\n### {t} ({err.splitlines()[0]})\n{s};")
|
||||||
|
if failed or miss:
|
||||||
|
print(f"\nFAILED: {len(failed) + len(miss)} 张表仍未就绪")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"ALL OK: {len(todo)} 张表已加 {COL} 列。页面的「移除 / 显示已完成」现在可用。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -18,15 +18,16 @@
|
||||||
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例)
|
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例)
|
||||||
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
|
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
|
||||||
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
|
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
|
||||||
研判请求体的数据库类型 (44 例)
|
研判请求体的数据库类型 / 研判应答归一(UNAVAILABLE) (48 例)
|
||||||
test_batch13_units.py 决策系统卖出采纳: 门槛分档/清场标记/确认加速/闭仓清场 (4 例)
|
test_batch13_units.py 决策系统卖出采纳: 门槛分档/清场标记/确认加速/闭仓清场 (4 例)
|
||||||
test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/
|
test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/
|
||||||
分方向触发/让路与冷却/宏观闸/建议采纳 (25 例)
|
分方向触发/让路与冷却/宏观闸/建议采纳 (25 例)
|
||||||
test_batch15_units.py 紧急清仓修复: 配额在途口径不重复扣/紧急清仓不被
|
test_batch15_units.py 紧急清仓修复: 配额在途口径不重复扣/紧急清仓不被
|
||||||
误判配额已出完/清仓命令即时撤策略/买入提议/在途买单;
|
误判配额已出完/清仓命令即时撤策略/买入提议/在途买单;
|
||||||
在途与配额文案拆分; 减仓不掐策略腿 (12 例)
|
在途与配额文案拆分; 减仓不掐策略腿 (12 例)
|
||||||
|
test_batch16_units.py 软归档 archived_at: 单表守卫/只归终态/列表默认排除 (5 例)
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
||||||
共 510 例
|
共 519 例
|
||||||
任一子集失败即整体失败 (退出码 1)。
|
任一子集失败即整体失败 (退出码 1)。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
@ -40,7 +41,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
|
||||||
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
|
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
|
||||||
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
|
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
|
||||||
"test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
|
"test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
|
||||||
"test_wiring.py"]
|
"test_batch16_units.py", "test_wiring.py"]
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,40 @@ def _():
|
||||||
"_recent_ledger 必须把 price_at 转成 float, 否则日志与留痕里带的是 Decimal"
|
"_recent_ledger 必须把 price_at 转成 float, 否则日志与留痕里带的是 Decimal"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================== 研判应答归一 (_map_verdict)
|
||||||
|
@case("研判应答·UNAVAILABLE 是设计内降级, 带出决策系统真实原因而不是「无法识别」")
|
||||||
|
def _():
|
||||||
|
got = jd._map_verdict({"verdict": "UNAVAILABLE", "reason": "该股无昨夜结论"})
|
||||||
|
assert got["verdict"] == jd.UNAVAILABLE, got
|
||||||
|
assert got["degraded"] is True, got
|
||||||
|
assert got["reason"] == "该股无昨夜结论", got
|
||||||
|
# 本次修复的回归点: 一条合法的 UNAVAILABLE 绝不能再被拼成「研判答复无法识别」
|
||||||
|
assert "无法识别" not in got["reason"], got
|
||||||
|
|
||||||
|
|
||||||
|
@case("研判应答·UNAVAILABLE 没带原因时给兜底话, 仍不是「无法识别」")
|
||||||
|
def _():
|
||||||
|
got = jd._map_verdict({"verdict": "unavailable"})
|
||||||
|
assert got["verdict"] == jd.UNAVAILABLE and got["degraded"] is True, got
|
||||||
|
assert got["reason"] == "决策系统研判不可用", got
|
||||||
|
assert "无法识别" not in got["reason"], got
|
||||||
|
|
||||||
|
|
||||||
|
@case("研判应答·真乱码才算「无法识别」(与合法 UNAVAILABLE 分开)")
|
||||||
|
def _():
|
||||||
|
got = jd._map_verdict({"verdict": "MAYBE_LATER"})
|
||||||
|
assert got["verdict"] == jd.UNAVAILABLE and got["degraded"] is True, got
|
||||||
|
assert "研判答复无法识别" in got["reason"], got
|
||||||
|
|
||||||
|
|
||||||
|
@case("研判应答·通过与驳回照常归一并带出理由")
|
||||||
|
def _():
|
||||||
|
p = jd._map_verdict({"verdict": "PASS", "reason": "驱动仍在"})
|
||||||
|
assert p["verdict"] == jd.PASS and p["degraded"] is False and p["reason"] == "驱动仍在", p
|
||||||
|
r = jd._map_verdict({"verdict": "驳回", "rationale": "放量破位"})
|
||||||
|
assert r["verdict"] == jd.REJECT and r["reason"] == "放量破位", r
|
||||||
|
|
||||||
|
|
||||||
# ================================================================ 参考位漂移
|
# ================================================================ 参考位漂移
|
||||||
@case("参考位漂移·只管新建仓, 别的动作一律不比对")
|
@case("参考位漂移·只管新建仓, 别的动作一律不比对")
|
||||||
def _():
|
def _():
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
第十六批: 软归档 (archived_at) 的 SQL 与口径守卫 —— 不连库, 打桩捕获 SQL
|
||||||
|
=====================================================================
|
||||||
|
软归档是「把终态记录从在办/在途视图移除、但绝不删行」。这批用例不连库, 用打桩截住
|
||||||
|
pms_repo 的 execute / fetch_all, 把真实生成的 SQL 抓出来核对三件事:
|
||||||
|
1. archive_* 生成的 UPDATE 只碰一张表 (过 assert_single_table 单表守卫), 只归档**终态**
|
||||||
|
(status IN 终态集) 且只动 archived_at 仍为空的行;
|
||||||
|
2. unarchive_* 只把 archived_at 清空, 不按状态过滤, 也过守卫;
|
||||||
|
3. list_* 默认 include_archived=False 时 SQL 带 archived_at IS NULL, 置 True 时不带 ——
|
||||||
|
保证已归档默认不出现在视图里, 而「显示已完成」能拉回来。
|
||||||
|
四类记录 (命令/策略/指令/提议) 的终态集与后端 archive_* 和前端可移除判据是同一份, 一并钉死。
|
||||||
|
运行: python scripts/test_batch16_units.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.repo import pms_repo as R # noqa: E402
|
||||||
|
from app.db.session import assert_single_table # noqa: E402
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(name):
|
||||||
|
def deco(fn):
|
||||||
|
RESULTS.append((name, fn))
|
||||||
|
return fn
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_execute():
|
||||||
|
cap = {}
|
||||||
|
|
||||||
|
def fake(sql, params=None):
|
||||||
|
cap["sql"] = sql
|
||||||
|
cap["params"] = params or {}
|
||||||
|
return 1
|
||||||
|
return cap, fake
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_fetch():
|
||||||
|
cap = {}
|
||||||
|
|
||||||
|
def fake(sql, params=None):
|
||||||
|
cap["sql"] = sql
|
||||||
|
cap["params"] = params or {}
|
||||||
|
return []
|
||||||
|
return cap, fake
|
||||||
|
|
||||||
|
|
||||||
|
def _run_capturing_execute(call):
|
||||||
|
cap, fake = _cap_execute()
|
||||||
|
orig = R.execute
|
||||||
|
R.execute = fake
|
||||||
|
try:
|
||||||
|
call()
|
||||||
|
finally:
|
||||||
|
R.execute = orig
|
||||||
|
return cap
|
||||||
|
|
||||||
|
|
||||||
|
@case("归档·archive_command 只碰单表 / 只归终态 / 带 archived_at IS NULL")
|
||||||
|
def _():
|
||||||
|
cap, fake = _cap_execute()
|
||||||
|
orig = R.execute
|
||||||
|
R.execute = fake
|
||||||
|
try:
|
||||||
|
n = R.archive_command("CMD_X")
|
||||||
|
finally:
|
||||||
|
R.execute = orig
|
||||||
|
assert n == 1, n
|
||||||
|
assert_single_table(cap["sql"]) # 守卫必须接受, 否则真机一执行就抛
|
||||||
|
assert "UPDATE pms_command" in cap["sql"], cap["sql"]
|
||||||
|
assert "archived_at IS NULL" in cap["sql"], cap["sql"]
|
||||||
|
assert {"DONE", "CANCELLED", "SUPERSEDED"} <= set(cap["params"].values()), cap["params"]
|
||||||
|
|
||||||
|
|
||||||
|
@case("归档·四类记录的终态集与设计一致 (改一处必须同时改前端可移除判据)")
|
||||||
|
def _():
|
||||||
|
assert R._ARCHIVE_TERMINAL["pms_command"] == ("DONE", "CANCELLED", "SUPERSEDED")
|
||||||
|
assert R._ARCHIVE_TERMINAL["pms_strategy"] == ("CANCELLED", "DONE")
|
||||||
|
assert R._ARCHIVE_TERMINAL["pms_instruction"] == ("CONFIRMED", "EXPIRED", "CANCELLED", "REJECTED")
|
||||||
|
assert R._ARCHIVE_TERMINAL["pms_proposal"] == ("ACCEPTED", "DECLINED", "EXPIRED")
|
||||||
|
|
||||||
|
|
||||||
|
@case("归档·strategy/instruction/proposal 三个 archive_* 都单表 + 终态 + archived_at IS NULL")
|
||||||
|
def _():
|
||||||
|
for fn, table, term in [
|
||||||
|
(R.archive_strategy, "pms_strategy", {"CANCELLED", "DONE"}),
|
||||||
|
(R.archive_instruction, "pms_instruction",
|
||||||
|
{"CONFIRMED", "EXPIRED", "CANCELLED", "REJECTED"}),
|
||||||
|
(R.archive_proposal, "pms_proposal", {"ACCEPTED", "DECLINED", "EXPIRED"}),
|
||||||
|
]:
|
||||||
|
cap = _run_capturing_execute(lambda fn=fn: fn("ID_X"))
|
||||||
|
assert_single_table(cap["sql"])
|
||||||
|
assert f"UPDATE {table}" in cap["sql"], cap["sql"]
|
||||||
|
assert "archived_at IS NULL" in cap["sql"], cap["sql"]
|
||||||
|
assert term <= set(cap["params"].values()), (table, cap["params"])
|
||||||
|
|
||||||
|
|
||||||
|
@case("恢复·unarchive_* 只清 archived_at, 不按状态过滤, 过守卫")
|
||||||
|
def _():
|
||||||
|
cap = _run_capturing_execute(lambda: R.unarchive_command("CMD_X"))
|
||||||
|
assert_single_table(cap["sql"])
|
||||||
|
assert "archived_at = NULL" in cap["sql"], cap["sql"]
|
||||||
|
assert "status" not in cap["sql"], "恢复显示不该按状态过滤 (已撤下也能恢复): " + cap["sql"]
|
||||||
|
|
||||||
|
|
||||||
|
@case("列表·默认排除已归档 (SQL 带 archived_at IS NULL); include_archived=True 时不带")
|
||||||
|
def _():
|
||||||
|
for lister in (R.list_commands, R.list_instructions, R.list_proposals, R.list_strategies):
|
||||||
|
cap, fake = _cap_fetch()
|
||||||
|
orig = R.fetch_all
|
||||||
|
R.fetch_all = fake
|
||||||
|
try:
|
||||||
|
lister() # 默认 include_archived=False
|
||||||
|
sql_default = cap["sql"]
|
||||||
|
lister(include_archived=True)
|
||||||
|
sql_all = cap["sql"]
|
||||||
|
finally:
|
||||||
|
R.fetch_all = orig
|
||||||
|
assert "archived_at IS NULL" in sql_default, (lister.__name__, sql_default)
|
||||||
|
assert "archived_at IS NULL" not in sql_all, (lister.__name__, sql_all)
|
||||||
|
assert_single_table(sql_default)
|
||||||
|
assert_single_table(sql_all)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
passed, failed = 0, []
|
||||||
|
for name, fn in RESULTS:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
passed += 1
|
||||||
|
print(f" ✓ {name}")
|
||||||
|
except Exception as e:
|
||||||
|
failed.append((name, e))
|
||||||
|
print(f" ✗ {name}: {type(e).__name__}: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"FAILED {len(failed)}/{len(RESULTS)}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"ALL PASS ({passed} cases)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue