feat(budget): effective-version caliber + process-order tabs + 4 missing rings
Task: CMA-20261003 (budget module governance). Local commit only; push is
gated on release approval (see report).
Backend
- app/utils/budget_status.py (new): single source for the effective-version
caliber. EFFECTIVE_STATUSES = approved + active; effective_plan_clause()
enforces entity isolation; pick_effective() priority active > approved >
updated_at desc. Fixes: approved budgets disappeared from the execution
surface because the three queries only read status='active'.
- budget.py: /comparison, /comparison/kpi/{id}, /deviation-check now read the
effective surface; versions list gains is_effective / can_release fields;
submit/approve are fail-closed (draft|active -> submitted -> approved).
- deviation_engine.get_budget_for_kpi: effective surface + deterministic pick.
- 4 missing rings, zero DDL, appended to the same router:
POST /versions/release (approved -> active, idempotent by operation_logs),
POST /adjustments (derive a new draft version, base rows untouched),
GET /control/status (effective budget vs actual -> ok/warn/over),
GET /assessment (achieve rate + A/B/C/D grade).
Audit rows written through app.utils.oplog.log_op with entity_id.
Frontend
- BudgetManagement.vue: top-level tabs reordered to the process order
(strategy, method, driver, input, decompose, versions, release, execution,
deviation, adjust, assess, rolling). Pane name values unchanged, so old
bookmarks keep working; routes unchanged.
- Cash classify rules / unclassified queue moved to /cash-plan
(components/CashClassifyPanel.vue); budget actual-value collection moved to
/data-manage (components/BudgetCollectPanel.vue). Mount points only: no
change to cash_plans data, classify logic, collection logic or kpi_values.
- api/index.ts: 4 ring endpoints exposed.
Docs / tests
- docs/budget-module-governance-design.md: phase-0 design doc (status machine,
tab order, domain boundaries, 4-ring design, cleanup write pack scope).
- tests/test_budget_status_machine.py (new): effective surface, version
guards, release idempotency, adjustment, control and assessment.
- tests/test_budget.py: existing cases adapted to the fail-closed contract.
(cherry picked from commit 4267fc4d25ee45c5675a541af9777be96fe9f9c2)
This commit is contained in:
+334
-17
@@ -14,6 +14,14 @@ from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetPlan, KPIDefinition, OperationLog, KPIValue
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
from app.utils.oplog import log_op
|
||||
from app.utils.budget_status import (
|
||||
EFFECTIVE_STATUSES,
|
||||
effective_plan_clause,
|
||||
effective_value_map,
|
||||
pick_effective,
|
||||
approval_status_of,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
@@ -730,18 +738,15 @@ def get_budget_comparison(
|
||||
)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
||||
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||
query = query.filter(
|
||||
BudgetPlan.period.in_(periods),
|
||||
BudgetPlan.status == "active",
|
||||
BudgetPlan.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
effective_plan_clause(entity_id), # 含账套隔离
|
||||
)
|
||||
budget_plans = query.all()
|
||||
|
||||
# 按KPI+期间索引
|
||||
bp_map = {}
|
||||
for bp in budget_plans:
|
||||
key = (bp.kpi_id, bp.period)
|
||||
bp_map[key] = bp.budget_value
|
||||
# 按KPI+期间索引(同键多版本 → 取生效行 active > approved > updated_at desc)
|
||||
bp_map = effective_value_map(budget_plans)
|
||||
|
||||
# 查询实际值
|
||||
kpi_ids = set(bp.kpi_id for bp in budget_plans)
|
||||
@@ -831,12 +836,13 @@ def get_kpi_comparison(
|
||||
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
|
||||
|
||||
# 预算值
|
||||
# 生效版口径(2026-10-03 状态机归一;保持原「未按账套过滤」行为不变)
|
||||
budgets = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period.in_(periods),
|
||||
BudgetPlan.status == "active",
|
||||
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||
).all()
|
||||
budget_map = {bp.period: bp.budget_value for bp in budgets}
|
||||
budget_map = {p: v for (_kid, p), v in effective_value_map(budgets).items()}
|
||||
|
||||
# 实际值
|
||||
actuals = db.query(KPIValue).filter(
|
||||
@@ -898,11 +904,16 @@ def check_budget_deviation(
|
||||
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 查询该期间有预算的KPI(多租户隔离 entity_id)
|
||||
budget_plans = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||
_effective_rows = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.status == "active",
|
||||
effective_plan_clause(entity_id),
|
||||
).all()
|
||||
# 同 KPI 多版本并存 → 取生效行(active > approved > updated_at desc),防重复告警
|
||||
_by_kpi = {}
|
||||
for _bp in _effective_rows:
|
||||
_by_kpi.setdefault(_bp.kpi_id, []).append(_bp)
|
||||
budget_plans = [r for r in (pick_effective(v) for v in _by_kpi.values()) if r is not None]
|
||||
|
||||
if not budget_plans:
|
||||
return {
|
||||
@@ -1520,7 +1531,7 @@ def list_budget_versions(
|
||||
# 状态机对齐(2026-09-07 UI修复):DB 默认态是 active(当前在用/草稿态),
|
||||
# 前端审批按钮语义用 draft=可提交审批/submitted=待审批/approved=已批准只读/rejected=已驳回
|
||||
# → approval_status 把 active 映射为 draft,同时保留 status 原字段兼容其他引用(下拉"当前"标记等)
|
||||
approval_status = "draft" if raw_status == "active" else raw_status
|
||||
approval_status = approval_status_of(raw_status) # 单一实现(预算状态机口径)
|
||||
updated_str = v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else ""
|
||||
result.append({
|
||||
"version": v["version"],
|
||||
@@ -1530,6 +1541,8 @@ def list_budget_versions(
|
||||
"plan_count": v["kpi_count"], # 前端列 prop 别名(BudgetManagement.vue versions tab)
|
||||
"total_budget": round(v["total_budget"], 2),
|
||||
"period_count": len(v["periods"]),
|
||||
"is_effective": raw_status in EFFECTIVE_STATUSES, # 是否执行基准
|
||||
"can_release": raw_status == "approved", # 是否可下达
|
||||
"year": year,
|
||||
"updated_at": updated_str,
|
||||
"last_updated": updated_str, # 前端列 prop 别名
|
||||
@@ -1552,10 +1565,13 @@ def submit_budget_version(
|
||||
).all()
|
||||
if not rows:
|
||||
raise HTTPException(404, f"版本 {version} 不存在")
|
||||
for r in rows:
|
||||
submittable = [r for r in rows if (r.status or "") in ("draft", "active")]
|
||||
if not submittable:
|
||||
raise HTTPException(400, f"版本 {version} 无可提交行(需 draft/active,当前: {sorted({r.status for r in rows})})")
|
||||
for r in submittable:
|
||||
r.status = "submitted"
|
||||
db.commit()
|
||||
return {"success": True, "version": version, "status": "submitted", "count": len(rows)}
|
||||
return {"success": True, "version": version, "status": "submitted", "count": len(submittable)}
|
||||
|
||||
|
||||
@router.post("/versions/approve")
|
||||
@@ -1574,10 +1590,13 @@ def approve_budget_version(
|
||||
if not rows:
|
||||
raise HTTPException(404, f"版本 {version} 不存在")
|
||||
new_status = "approved" if action == "approved" else "rejected"
|
||||
for r in rows:
|
||||
decidable = [r for r in rows if (r.status or "") == "submitted"]
|
||||
if not decidable:
|
||||
raise HTTPException(400, f"版本 {version} 无可审批行(需 submitted,当前: {sorted({r.status for r in rows})})")
|
||||
for r in decidable:
|
||||
r.status = new_status
|
||||
db.commit()
|
||||
return {"success": True, "version": version, "status": new_status, "count": len(rows)}
|
||||
return {"success": True, "version": version, "status": new_status, "count": len(decidable)}
|
||||
|
||||
|
||||
@router.post("/versions/diff")
|
||||
@@ -1781,3 +1800,301 @@ def sync_cash_plans(
|
||||
"unclassified_count": unclassified_count,
|
||||
"skipped_annual": skipped_annual,
|
||||
}
|
||||
|
||||
|
||||
# ============ 预算闭环补齐(2026-10-03 施工单 CMA-20261003 阶段 3:最小可用骨架) ============
|
||||
# 设计件 docs/budget-module-governance-design.md §4:控制 / 调整 / 下达 / 考核
|
||||
# 零 DDL:全部基于既有 budget_plans / kpi_values / operation_logs。
|
||||
|
||||
DEFAULT_CONTROL_THRESHOLD_PCT = 20.0
|
||||
|
||||
|
||||
def _budget_control_threshold(db) -> float:
|
||||
"""控制阈值(%):system_configs.budget_control_threshold,缺省 20%。"""
|
||||
from app.models import SystemConfig
|
||||
import json as _json
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "budget_control_threshold"
|
||||
).first()
|
||||
if cfg:
|
||||
try:
|
||||
return float(_json.loads(cfg.config_value))
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_CONTROL_THRESHOLD_PCT
|
||||
return DEFAULT_CONTROL_THRESHOLD_PCT
|
||||
|
||||
|
||||
def _effective_rows_for_periods(db, entity_id: int, periods):
|
||||
"""期间集合内的生效预算行(同键取生效行,防同 KPI 多版本重复计算)。"""
|
||||
rows = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.period.in_(periods),
|
||||
effective_plan_clause(entity_id),
|
||||
).all()
|
||||
grouped = {}
|
||||
for r in rows:
|
||||
grouped.setdefault((r.kpi_id, r.period), []).append(r)
|
||||
out = []
|
||||
for key, rows_of_key in grouped.items():
|
||||
row = pick_effective(rows_of_key)
|
||||
if row is not None:
|
||||
out.append((key[0], key[1], row))
|
||||
return out
|
||||
|
||||
|
||||
def _direction_of(kpi, higher_better_codes) -> str:
|
||||
return "higher" if (kpi.kpi_code in higher_better_codes or kpi.kpi_name in higher_better_codes) else "lower"
|
||||
|
||||
|
||||
@router.get("/control/status")
|
||||
def get_budget_control_status(
|
||||
year: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预算控制状态(缺失环「控制」最小骨架,只读):生效预算 vs 实际 → ok/warn/over。"""
|
||||
from app.utils.deviation_engine import get_higher_better_codes
|
||||
y = year or datetime.now().year
|
||||
periods = [period] if period else [f"{y}-{m:02d}" for m in range(1, 13)]
|
||||
threshold = _budget_control_threshold(db)
|
||||
|
||||
eff = _effective_rows_for_periods(db, entity_id, periods)
|
||||
kpi_ids = {kid for kid, _p, _r in eff}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
actuals = {}
|
||||
if kpi_ids:
|
||||
for v in db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id.in_(kpi_ids),
|
||||
KPIValue.period.in_(periods),
|
||||
KPIValue.actual_value.isnot(None),
|
||||
actual_only_clause(),
|
||||
).all():
|
||||
actuals[(v.kpi_id, v.period)] = v.actual_value
|
||||
higher_better = set(get_higher_better_codes(db, entity_id))
|
||||
|
||||
items = []
|
||||
summary = {"total": 0, "ok": 0, "warn": 0, "over": 0, "no_actual": 0}
|
||||
for kid, per, plan in sorted(eff, key=lambda x: (x[0], x[1])):
|
||||
kpi = kpis.get(kid)
|
||||
if kpi is None:
|
||||
continue
|
||||
budget = plan.budget_value or 0
|
||||
actual = actuals.get((kid, per))
|
||||
direction = _direction_of(kpi, higher_better)
|
||||
level, rate, deviation_rate = "no_actual", None, None
|
||||
if actual is not None and budget:
|
||||
rate = round(actual / budget * 100, 2)
|
||||
deviation_rate = round((actual - budget) / abs(budget) * 100, 2)
|
||||
if direction == "higher":
|
||||
level = "over" if deviation_rate <= -threshold else ("warn" if deviation_rate <= -threshold * 0.8 else "ok")
|
||||
else:
|
||||
level = "over" if deviation_rate >= threshold else ("warn" if deviation_rate >= threshold * 0.8 else "ok")
|
||||
summary["total"] += 1
|
||||
summary[level] = summary.get(level, 0) + 1
|
||||
items.append({
|
||||
"kpi_id": kid,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"unit": kpi.unit or "",
|
||||
"period": per,
|
||||
"basis_version": plan.version,
|
||||
"basis_status": plan.status,
|
||||
"direction": direction,
|
||||
"budget_value": budget,
|
||||
"actual_value": actual,
|
||||
"execution_rate": rate,
|
||||
"deviation_rate": deviation_rate,
|
||||
"control_level": level,
|
||||
})
|
||||
return {"year": y, "period": period, "threshold_pct": threshold, "summary": summary, "items": items}
|
||||
|
||||
|
||||
@router.get("/assessment")
|
||||
def get_budget_assessment(
|
||||
year: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预算考核(缺失环「考核」最小骨架,只读):生效预算 vs 实际 → 达成率 + 等级 A/B/C/D。"""
|
||||
from app.utils.deviation_engine import get_higher_better_codes
|
||||
y = year or datetime.now().year
|
||||
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
|
||||
|
||||
eff = _effective_rows_for_periods(db, entity_id, periods)
|
||||
kpi_ids = {kid for kid, _p, _r in eff}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
actuals = {}
|
||||
if kpi_ids:
|
||||
for v in db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id.in_(kpi_ids),
|
||||
KPIValue.period.in_(periods),
|
||||
KPIValue.actual_value.isnot(None),
|
||||
actual_only_clause(),
|
||||
).all():
|
||||
actuals[(v.kpi_id, v.period)] = v.actual_value
|
||||
higher_better = set(get_higher_better_codes(db, entity_id))
|
||||
|
||||
agg = {}
|
||||
for kid, per, plan in eff:
|
||||
row = agg.setdefault(kid, {"budget_total": 0.0, "actual_total": 0.0, "periods": set(), "basis_version": plan.version,
|
||||
"basis_status": plan.status, "_ts": plan.updated_at})
|
||||
row["budget_total"] += plan.budget_value or 0
|
||||
row["periods"].add(per)
|
||||
if plan.updated_at and (row["_ts"] is None or plan.updated_at > row["_ts"]):
|
||||
row["_ts"] = plan.updated_at
|
||||
row["basis_version"] = plan.version
|
||||
row["basis_status"] = plan.status
|
||||
if (kid, per) in actuals:
|
||||
row["actual_total"] += actuals[(kid, per)] or 0
|
||||
|
||||
items = []
|
||||
summary = {"kpi_count": 0, "a": 0, "b": 0, "c": 0, "d": 0, "na": 0,
|
||||
"total_budget": 0.0, "total_actual": 0.0}
|
||||
for kid, row in sorted(agg.items()):
|
||||
kpi = kpis.get(kid)
|
||||
if kpi is None:
|
||||
continue
|
||||
direction = _direction_of(kpi, higher_better)
|
||||
bt, at = round(row["budget_total"], 2), round(row["actual_total"], 2)
|
||||
achieve, grade = None, "NA"
|
||||
if bt and row["periods"]:
|
||||
achieve = round((at / bt * 100) if direction == "higher" else (bt / at * 100 if at else 0.0), 2)
|
||||
grade = "A" if achieve >= 100 else ("B" if achieve >= 90 else ("C" if achieve >= 80 else "D"))
|
||||
summary["kpi_count"] += 1
|
||||
summary[grade.lower()] = summary.get(grade.lower(), 0) + 1
|
||||
summary["total_budget"] += bt
|
||||
summary["total_actual"] += at
|
||||
items.append({
|
||||
"kpi_id": kid,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"unit": kpi.unit or "",
|
||||
"direction": direction,
|
||||
"basis_version": row["basis_version"],
|
||||
"basis_status": row["basis_status"],
|
||||
"period_count": len(row["periods"]),
|
||||
"budget_total": bt,
|
||||
"actual_total": at,
|
||||
"achieve_rate": achieve,
|
||||
"grade": grade,
|
||||
})
|
||||
summary["total_budget"] = round(summary["total_budget"], 2)
|
||||
summary["total_actual"] = round(summary["total_actual"], 2)
|
||||
summary["achieve_rate"] = round(summary["total_actual"] / summary["total_budget"] * 100, 2) if summary["total_budget"] else None
|
||||
return {"year": y, "summary": summary, "items": items}
|
||||
|
||||
|
||||
@router.post("/versions/release")
|
||||
def release_budget_version(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预算下达(缺失环「下达」):已批准版本 → 已下达(active)。fail-closed + 幂等。"""
|
||||
version = (data.get("version") or "").strip()
|
||||
if not version:
|
||||
raise HTTPException(400, "需要 version")
|
||||
rows = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.version == version,
|
||||
).all()
|
||||
if not rows:
|
||||
raise HTTPException(404, f"版本 {version} 不存在")
|
||||
approved = [r for r in rows if (r.status or "") == "approved"]
|
||||
if not approved:
|
||||
# 幂等判据 = 「本版本确有下达历史」(operation_logs 记录),而不是 active 现状:
|
||||
# 迁移前遗留的 active 行 = 在用/草稿态,不能当成「已下达」(设计件 §1.2/§1.3)。
|
||||
released_before = any(
|
||||
isinstance(l.detail, dict)
|
||||
and l.detail.get("op") == "release"
|
||||
and l.detail.get("version") == version
|
||||
for l in db.query(OperationLog)
|
||||
.filter(OperationLog.target_type == "budget_version")
|
||||
.order_by(OperationLog.id.desc())
|
||||
.limit(500)
|
||||
.all()
|
||||
)
|
||||
if released_before and any((r.status or "") == "active" for r in rows):
|
||||
return {"success": True, "version": version, "status": "active", "count": 0, "already": True}
|
||||
raise HTTPException(400, f"版本 {version} 不可下达(需 approved,当前: {sorted({r.status for r in rows})})")
|
||||
for r in approved:
|
||||
r.status = "active"
|
||||
log_op(db, "update", target_type="budget_version", target_id=None,
|
||||
detail={"op": "release", "version": version, "count": len(approved),
|
||||
"from_status": "approved", "to_status": "active"},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
return {"success": True, "version": version, "status": "active", "count": len(approved), "already": False}
|
||||
|
||||
|
||||
@router.post("/adjustments")
|
||||
def create_budget_adjustment(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预算调整(缺失环「调整」最小骨架):基于生效基线版本派生新版本(draft),原版本零改动。"""
|
||||
base_version = (data.get("base_version") or "").strip()
|
||||
new_version = (data.get("new_version") or "").strip()
|
||||
reason = (data.get("reason") or "").strip()
|
||||
items = data.get("items") or []
|
||||
if not base_version or not new_version:
|
||||
raise HTTPException(400, "需要 base_version 与 new_version")
|
||||
if new_version == base_version:
|
||||
raise HTTPException(400, "new_version 不能与 base_version 相同")
|
||||
if not reason:
|
||||
raise HTTPException(400, "调整单必须填写 reason(可追溯)")
|
||||
if db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id, BudgetPlan.version == new_version
|
||||
).first():
|
||||
raise HTTPException(400, f"版本 {new_version} 已存在(调整单只允许派生新版本,不覆盖)")
|
||||
base_rows = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.version == base_version,
|
||||
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||
).all()
|
||||
if not base_rows:
|
||||
raise HTTPException(404, f"基线版本 {base_version} 不存在或非生效版")
|
||||
|
||||
overrides = {}
|
||||
for it in items:
|
||||
kid, per = it.get("kpi_id"), it.get("period")
|
||||
if kid is None or not per or it.get("budget_value") is None:
|
||||
raise HTTPException(400, "items 每项需 kpi_id / period / budget_value")
|
||||
overrides[(int(kid), per)] = float(it["budget_value"])
|
||||
|
||||
operator = current_user.name if hasattr(current_user, "name") else ""
|
||||
applied, unmatched = 0, len(overrides)
|
||||
for r in base_rows:
|
||||
key = (r.kpi_id, r.period)
|
||||
if key in overrides:
|
||||
val = overrides.pop(key)
|
||||
applied += 1
|
||||
unmatched -= 1
|
||||
else:
|
||||
val = r.budget_value
|
||||
db.add(BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
map_id=r.map_id,
|
||||
kpi_id=r.kpi_id,
|
||||
period=r.period,
|
||||
budget_value=val,
|
||||
budget_year=r.budget_year,
|
||||
budget_month=r.budget_month,
|
||||
version=new_version,
|
||||
status="draft",
|
||||
source_type=r.source_type or "manual",
|
||||
calc_logic=r.calc_logic,
|
||||
remark=f"调整单:{reason} 基线:{base_version}"[:500],
|
||||
created_by=operator,
|
||||
))
|
||||
log_op(db, "create", target_type="budget_adjustment", target_id=None,
|
||||
detail={"base_version": base_version, "new_version": new_version, "reason": reason,
|
||||
"copied": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
return {"success": True, "new_version": new_version, "base_version": base_version, "reason": reason,
|
||||
"created": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched,
|
||||
"status": "draft", "note": "调整版本已生成为 draft,需提交审批 → 批准 → 下达后方可作为执行基准"}
|
||||
|
||||
Reference in New Issue
Block a user