feat: 复盘结果持久化 — 大PDCA Act闭环(P1, yanxue-review-persistence)

- review_records表+模型(entity_id多租户)
- API: POST/GET/GET:id /maps/{id}/review-records
- 前端: MapReview 保存复盘结论弹窗+历史记录时间线(并发agent完成主体, 本项目补齐script逻辑)
- 验证: POST 200保存成功/GET列表/跨企业404/pytest 11 passed/构建部署
This commit is contained in:
Hermes CI Fix
2026-08-27 15:59:55 +08:00
parent fdc42d443d
commit 9f787e628b
5 changed files with 251 additions and 2 deletions
+109 -2
View File
@@ -2,11 +2,11 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
@@ -785,3 +785,110 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"action_plans": action_plans_data,
}
# ── 复盘记录持久化(大PDCA Act闭环 2026-08-27)────────────────
def review_to_dict(r: ReviewRecord) -> dict:
return {
"id": r.id,
"entity_id": r.entity_id,
"map_id": r.map_id,
"review_type": r.review_type,
"review_date": r.review_date.isoformat() if r.review_date else None,
"summary": r.summary,
"adjustments": r.adjustments,
"next_priorities": r.next_priorities,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _parse_review_date(raw) -> Optional[date]:
"""兼容 'YYYY-MM-DD' / datetime / date 输入"""
if raw is None:
return datetime.now().date()
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
try:
return datetime.strptime(str(raw)[:10], "%Y-%m-%d").date()
except ValueError:
return datetime.now().date()
def _get_map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
return m
@router.post("/{map_id}/review-records")
def create_review_record(
map_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""保存复盘结论(战略回顾会/运营复盘会结束时落库)"""
_get_map_or_404(db, map_id, entity_id)
summary = (data.get("summary") or "").strip()
if not summary:
raise HTTPException(400, "复盘结论(summary)不能为空")
r = ReviewRecord(
entity_id=entity_id, # 多租户隔离: 强制token企业
map_id=map_id,
review_type=data.get("review_type", "quarterly"),
review_date=_parse_review_date(data.get("review_date")),
summary=summary,
adjustments=data.get("adjustments") or [],
next_priorities=data.get("next_priorities") or [],
created_by=current_user.id if current_user else None,
)
db.add(r)
db.commit()
db.refresh(r)
return review_to_dict(r)
@router.get("/{map_id}/review-records")
def list_review_records(
map_id: int,
review_type: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""历史复盘记录列表(按复盘日期倒序)"""
_get_map_or_404(db, map_id, entity_id)
q = db.query(ReviewRecord).filter(
ReviewRecord.map_id == map_id,
ReviewRecord.entity_id == entity_id,
)
if review_type:
q = q.filter(ReviewRecord.review_type == review_type)
records = q.order_by(ReviewRecord.review_date.desc(), ReviewRecord.id.desc()).all()
return {"data": [review_to_dict(r) for r in records]}
@router.get("/{map_id}/review-records/{record_id}")
def get_review_record(
map_id: int,
record_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""单条复盘记录详情"""
_get_map_or_404(db, map_id, entity_id)
r = db.query(ReviewRecord).filter(
ReviewRecord.id == record_id,
ReviewRecord.map_id == map_id,
ReviewRecord.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "复盘记录不存在")
return review_to_dict(r)
+16
View File
@@ -273,6 +273,22 @@ class StrategicMapVersion(Base):
created_at = Column(DateTime, server_default=func.now())
class ReviewRecord(Base):
"""复盘记录 — 战略回顾会/运营复盘会结论持久化(大PDCA Act闭环 2026-08-27"""
__tablename__ = "review_records"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联战略地图")
review_type = Column(String(20), default="quarterly", comment="monthly/quarterly 月度运营复盘/季度战略回顾")
review_date = Column(Date, nullable=False, comment="复盘日期")
summary = Column(Text, nullable=False, comment="复盘结论(为什么没达成/怎么调整)")
adjustments = Column(JSON, nullable=True, comment="调整项JSON [{type,target,action}]")
next_priorities = Column(JSON, nullable=True, comment="下阶段优先级 [str,...]")
created_by = Column(Integer, nullable=True, comment="创建人用户ID")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class MapObjective(Base):
"""战略地图目标: 每个维度下的具体目标"""
__tablename__ = "map_objectives"
+1
View File
@@ -16,6 +16,7 @@ declare module 'vue' {
DriverFactorBudget: typeof import('./src/components/DriverFactorBudget.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+7
View File
@@ -58,6 +58,13 @@ export const mapApi = {
createWithTemplate: (data: any) => api.post('/maps/create-with-template', data),
}
// 复盘记录持久化(大PDCA Act闭环 2026-08-27
export const reviewRecordApi = {
list: (mapId: number, params?: any) => api.get(`/maps/${mapId}/review-records`, { params }),
get: (mapId: number, id: number) => api.get(`/maps/${mapId}/review-records/${id}`),
create: (mapId: number, data: any) => api.post(`/maps/${mapId}/review-records`, data),
}
export const dashboardApi = {
summary: (role: string) => api.get('/dashboard/summary', { params: { role } }),
kpis: (params: any) => api.get('/dashboard/kpis', { params }),
+118
View File
@@ -37,6 +37,17 @@
</el-tag>
</div>
<!-- 复盘结论持久化工具条大PDCA Act闭环 2026-08-27 -->
<div class="review-toolbar">
<el-button type="primary" :disabled="!selectedMap || !summary" @click="openSaveDialog">
📝 保存本次复盘结论
</el-button>
<el-button :disabled="!selectedMap" @click="showHistoryDialog = true">
📚 历史复盘记录
<el-badge v-if="reviewRecords.length > 0" :value="reviewRecords.length" type="primary" style="margin-left:6px;" />
</el-button>
</div>
<!-- 运营复盘会特殊内容 -->
<template v-if="meetingType === 'operational' && !loading && summary">
<div class="op-agenda-cards">
@@ -283,6 +294,52 @@
</div>
</div>
</el-dialog>
<!-- 保存复盘结论弹窗大PDCA Act闭环 2026-08-27 -->
<el-dialog v-model="saveDialogVisible" title="📝 保存复盘结论" width="560">
<el-form label-width="110px">
<el-form-item label="复盘类型">
<el-select v-model="reviewForm.review_type" style="width: 200px;">
<el-option label="月度运营复盘" value="monthly" />
<el-option label="季度战略回顾" value="quarterly" />
</el-select>
</el-form-item>
<el-form-item label="复盘日期">
<el-date-picker v-model="reviewForm.review_date" type="date" value-format="YYYY-MM-DD" style="width: 200px;" />
</el-form-item>
<el-form-item label="复盘结论">
<el-input v-model="reviewForm.summary" type="textarea" :rows="3"
placeholder="为什么没达成 / 关键偏差原因 / 需要怎么调整" />
</el-form-item>
<el-form-item label="调整项(JSON)">
<el-input v-model="reviewForm.adjustments_text" type="textarea" :rows="2"
placeholder='[{"type":"kpi","target":"F_REVENUE","action":"调高目标"}]' />
</el-form-item>
<el-form-item label="下期优先级">
<el-input v-model="reviewForm.next_priorities_text" type="textarea" :rows="2"
placeholder='["聚焦营收增长", "改善现金流"]' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveReview">保存</el-button>
</template>
</el-dialog>
<!-- 历史复盘记录弹窗 -->
<el-dialog v-model="showHistoryDialog" title="📚 历史复盘记录" width="600">
<div v-if="reviewRecords.length === 0" style="color:#909399;padding:20px;text-align:center;">暂无历史复盘记录</div>
<el-timeline v-else>
<el-timeline-item v-for="rec in reviewRecords" :key="rec.id" :timestamp="rec.review_date + ' · ' + (rec.review_type === 'quarterly' ? '季度战略回顾' : '月度运营复盘')" placement="top">
<el-card shadow="never" style="border:1px solid #e2e8f0;">
<div style="font-size:13px;color:#334155;white-space:pre-wrap;">{{ rec.summary }}</div>
<div v-if="rec.next_priorities?.length" style="margin-top:8px;font-size:12px;color:#64748b;">
下期优先级{{ rec.next_priorities.join(' / ') }}
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-dialog>
</div>
</template>
@@ -306,6 +363,18 @@ const dimensions = ref<any[]>([])
const focusItems = ref<any[]>([])
const actionPlans = ref<any[]>([])
const showKpiDialog = ref(false)
// PDCA Act 2026-08-27
const reviewRecords = ref<any[]>([])
const saveDialogVisible = ref(false)
const showHistoryDialog = ref(false)
const saving = ref(false)
const reviewForm = reactive({
review_type: "quarterly",
review_date: "",
summary: "",
adjustments_text: "",
next_priorities_text: "",
})
const selectedObjName = ref("")
const selectedKpis = ref<any[]>([])
@@ -335,6 +404,7 @@ async function loadReview() {
//
await loadDeviations()
await loadKrs()
await loadReviewRecords()
} catch (e: any) {
ElMessage.error("加载战略回顾数据失败")
} finally {
@@ -366,6 +436,54 @@ async function loadKrs() {
}
}
// /PDCA Act 2026-08-27
async function loadReviewRecords() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review-records`)
reviewRecords.value = r.data || []
} catch (_) {
reviewRecords.value = []
}
}
function openSaveDialog() {
reviewForm.review_type = "quarterly"
reviewForm.review_date = new Date().toISOString().slice(0, 10)
reviewForm.summary = ""
reviewForm.adjustments_text = ""
reviewForm.next_priorities_text = ""
saveDialogVisible.value = true
}
async function saveReview() {
if (!selectedMap.value || !reviewForm.summary.trim()) {
ElMessage.warning("请填写复盘结论")
return
}
saving.value = true
try {
let adjustments: any[] = []
let nextPriorities: string[] = []
try { adjustments = reviewForm.adjustments_text ? JSON.parse(reviewForm.adjustments_text) : [] } catch { ElMessage.warning("调整项JSON格式错误"); return }
try { nextPriorities = reviewForm.next_priorities_text ? JSON.parse(reviewForm.next_priorities_text) : [] } catch { ElMessage.warning("优先级JSON格式错误"); return }
await api.post(`/maps/${selectedMap.value}/review-records`, {
review_type: reviewForm.review_type,
review_date: reviewForm.review_date || new Date().toISOString().slice(0, 10),
summary: reviewForm.summary,
adjustments,
next_priorities: nextPriorities,
})
ElMessage.success("复盘结论已保存")
saveDialogVisible.value = false
await loadReviewRecords()
} catch (e: any) {
ElMessage.error("保存失败: " + (e?.message || ""))
} finally {
saving.value = false
}
}
//
function onMeetingTypeChange() {
if (selectedMap.value) {