diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index 2e7586a7..dd2bbc15 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -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) + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index aa869afd..7e257f1a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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" diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 85312a58..ddb11f89 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index feb049bd..366d6a76 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 }), diff --git a/frontend/src/views/MapReview.vue b/frontend/src/views/MapReview.vue index 306043a4..1b0ecc8b 100644 --- a/frontend/src/views/MapReview.vue +++ b/frontend/src/views/MapReview.vue @@ -37,6 +37,17 @@ + +
+ + 📝 保存本次复盘结论 + + + 📚 历史复盘记录 + + +
+ @@ -306,6 +363,18 @@ const dimensions = ref([]) const focusItems = ref([]) const actionPlans = ref([]) const showKpiDialog = ref(false) +// ── 复盘持久化(大PDCA Act闭环 2026-08-27) ── +const reviewRecords = ref([]) +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([]) @@ -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) {