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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role 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 import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"], 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, "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()) 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): class MapObjective(Base):
"""战略地图目标: 每个维度下的具体目标""" """战略地图目标: 每个维度下的具体目标"""
__tablename__ = "map_objectives" __tablename__ = "map_objectives"
+1
View File
@@ -16,6 +16,7 @@ declare module 'vue' {
DriverFactorBudget: typeof import('./src/components/DriverFactorBudget.vue')['default'] DriverFactorBudget: typeof import('./src/components/DriverFactorBudget.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] 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), 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 = { export const dashboardApi = {
summary: (role: string) => api.get('/dashboard/summary', { params: { role } }), summary: (role: string) => api.get('/dashboard/summary', { params: { role } }),
kpis: (params: any) => api.get('/dashboard/kpis', { params }), kpis: (params: any) => api.get('/dashboard/kpis', { params }),
+118
View File
@@ -37,6 +37,17 @@
</el-tag> </el-tag>
</div> </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"> <template v-if="meetingType === 'operational' && !loading && summary">
<div class="op-agenda-cards"> <div class="op-agenda-cards">
@@ -283,6 +294,52 @@
</div> </div>
</div> </div>
</el-dialog> </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> </div>
</template> </template>
@@ -306,6 +363,18 @@ const dimensions = ref<any[]>([])
const focusItems = ref<any[]>([]) const focusItems = ref<any[]>([])
const actionPlans = ref<any[]>([]) const actionPlans = ref<any[]>([])
const showKpiDialog = ref(false) 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 selectedObjName = ref("")
const selectedKpis = ref<any[]>([]) const selectedKpis = ref<any[]>([])
@@ -335,6 +404,7 @@ async function loadReview() {
// 联动加载偏差数据 // 联动加载偏差数据
await loadDeviations() await loadDeviations()
await loadKrs() await loadKrs()
await loadReviewRecords()
} catch (e: any) { } catch (e: any) {
ElMessage.error("加载战略回顾数据失败") ElMessage.error("加载战略回顾数据失败")
} finally { } 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() { function onMeetingTypeChange() {
if (selectedMap.value) { if (selectedMap.value) {