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:
+109
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user