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