Jelajahi Sumber

增加运营总览报表

chenjun 3 minggu lalu
induk
melakukan
1c3d625947

+ 216 - 125
src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue

@@ -3,81 +3,90 @@
     <div class="long-order-print" id="longOrderPrintArea" v-loading="loading">
       <h2 class="doc-title">长期医嘱单</h2>
 
-      <!-- 基本信息 -->
+      <!-- 基本信息:参照纸质长期医嘱单(住院号、科室不打印) -->
       <div class="info-row">
-        <div class="info-cell"><span class="lbl">姓名:</span>{{ form.elderName || '' }}</div>
-        <div class="info-cell"><span class="lbl">性别:</span>{{ genderText }}</div>
-        <div class="info-cell"><span class="lbl">年龄:</span>{{ form.elderAge || '' }}</div>
-        <div class="info-cell"><span class="lbl">床号:</span>{{ form.bedName || '' }}</div>
-        <div class="info-cell">
-          <span class="lbl">护理等级:</span>{{ form.nurseLevelName || '' }}
+        <div class="info-item">
+          <span class="lbl">姓名:</span>
+          <span class="val">{{ form.elderName || '' }}</span>
+        </div>
+        <div class="info-item">
+          <span class="lbl">性别:</span>
+          <span class="val">{{ genderText }}</span>
+        </div>
+        <div class="info-item">
+          <span class="lbl">年龄:</span>
+          <span class="val">{{ ageText }}</span>
+        </div>
+        <div class="info-item">
+          <span class="lbl">护理等级:</span>
+          <span class="val">{{ form.nurseLevelName || '' }}</span>
+        </div>
+        <div class="info-item">
+          <span class="lbl">床号:</span>
+          <span class="val bed">{{ form.bedName || '' }}</span>
         </div>
       </div>
 
-      <!-- 医嘱表(屏幕预览用 % 列宽,弹填 dialog 95% 容器;打印纸张 A4 portrait 时按父容器 100% 等比缩放)。
-           9-08 调整:去除「护士签名/停止护士签名」两列后,由原 12 列变 10 列,
-           释放的 ~12.3% 重新分配到医嘱内容、备注、医嘱时间/停止时间等列。 -->
+      <!--
+        纸质长期医嘱单结构(7 列):
+        开始(时间/医生/护士) | 长期医嘱(序号+内容+途径+给药时间+频次) | 停止(时间/医生/护士)
+        同一开始/停止时间连续相同时,时间单元格纵向合并(对应纸质单上的“|”承接记号),
+        医生、护士签名每行照常显示。
+      -->
       <table class="order-table">
         <colgroup>
-          <col style="width: 3.5%" />
-          <col style="width: 10.5%" />
-          <col style="width: 30%" />
-          <col style="width: 6%" />
-          <col style="width: 6%" />
-          <col style="width: 6%" />
+          <col style="width: 12%" />
+          <col style="width: 7%" />
+          <col style="width: 7%" />
+          <col style="width: 48%" />
+          <col style="width: 12%" />
           <col style="width: 7%" />
-          <col style="width: 10.5%" />
           <col style="width: 7%" />
-          <col style="width: 13.5%" />
         </colgroup>
         <thead>
           <tr>
-            <th>序号</th>
-            <th>医嘱时间</th>
-            <th>医嘱内容</th>
-            <th>频次</th>
-            <th>给药时间</th>
-            <th>途径</th>
-            <th>医生<br />签名</th>
-            <th>停止时间</th>
-            <th>停止<br />医生签名</th>
-            <th>备注</th>
+            <th class="group-head" colspan="3">开始</th>
+            <th class="order-head" rowspan="2">长期医嘱</th>
+            <th class="group-head stop" colspan="3">停止</th>
+          </tr>
+          <tr>
+            <th class="sub-head">时间</th>
+            <th class="sub-head">医生</th>
+            <th class="sub-head">护士</th>
+            <th class="sub-head">时间</th>
+            <th class="sub-head">医生</th>
+            <th class="sub-head">护士</th>
           </tr>
         </thead>
         <tbody>
-          <tr v-for="(item, idx) in paddedEntries" :key="idx">
-            <td>
-              <span class="cell-value">{{ idx + 1 }}</span>
-            </td>
-            <td>
+          <tr v-for="(item, idx) in renderRows" :key="idx">
+            <!-- 开始时间(连续相同合并) -->
+            <td v-if="item._startSpan > 0" :rowspan="item._startSpan" class="time-cell">
               <div class="datetime">
                 <div class="d-line">{{ formatDateTimeSplit(item.orderTime).date }}</div>
                 <div class="t-line">{{ formatDateTimeSplit(item.orderTime).time }}</div>
               </div>
             </td>
-            <td>
-              <span class="cell-value pre">{{ item.orderContent || '' }}</span>
-            </td>
-            <td>
-              <span class="cell-value">{{ getMedCn(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
-            </td>
-            <td>
-              <span class="cell-value">{{ getMedCn(MED_TIME_OPTIONS, item.medTime) }}</span>
-            </td>
-            <td>
-              <span class="cell-value">{{ getMedCn(MED_ROUTE_OPTIONS, item.route) }}</span>
+            <td class="sign-cell">{{ item.doctorSign || '' }}</td>
+            <td class="sign-cell">{{ item.nurseSign || '' }}</td>
+            <!-- 长期医嘱内容 -->
+            <td class="content-cell">
+              <div class="oe-line">
+                <span class="oe-no">{{ idx + 1 }}</span>
+                <span class="oe-text">{{ item.orderContent || '' }}</span>
+                <span class="oe-meta">{{ buildMeta(item) }}</span>
+              </div>
+              <div v-if="item.remark" class="oe-remark">备注:{{ item.remark }}</div>
             </td>
-            <td>{{ item.doctorSign || '' }}</td>
-            <td>
+            <!-- 停止时间(连续相同合并) -->
+            <td v-if="item._stopSpan > 0" :rowspan="item._stopSpan" class="time-cell">
               <div class="datetime">
                 <div class="d-line">{{ formatDateTimeSplit(item.stopTime).date }}</div>
                 <div class="t-line">{{ formatDateTimeSplit(item.stopTime).time }}</div>
               </div>
             </td>
-            <td>{{ item.stopDoctorSign || '' }}</td>
-            <td>
-              <span class="cell-value pre">{{ item.remark || '' }}</span>
-            </td>
+            <td class="sign-cell">{{ item.stopDoctorSign || '' }}</td>
+            <td class="sign-cell">{{ item.stopNurseSign || '' }}</td>
           </tr>
         </tbody>
       </table>
@@ -124,10 +133,29 @@ const genderText = computed(() =>
     : getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender)
 )
 
+const ageText = computed(() => {
+  const age = form.elderAge
+  if (age === '' || age === null || age === undefined) return ''
+  return `${age}岁`
+})
+
 /** 打印页最少展示 6 行,不足补空行 */
 const MIN_ROWS = 6
-const paddedEntries = computed<LongDoctorOrderEntry[]>(() => {
-  const list = form.entries || []
+
+interface RenderRow extends LongDoctorOrderEntry {
+  /** 开始时间单元格 rowspan:>0 渲染且合并,0 表示被上一行合并、不渲染 td */
+  _startSpan: number
+  /** 停止时间单元格 rowspan,规则同上 */
+  _stopSpan: number
+}
+
+/**
+ * 生成表格行:在 entry 基础上计算连续相同时间的合并跨度。
+ * - 仅非空时间参与合并,空补行各自独立;
+ * - 比较口径为格式化后的“YYYY-MM-DD HH:mm”整串。
+ */
+const renderRows = computed<RenderRow[]>(() => {
+  const list: LongDoctorOrderEntry[] = form.entries || []
   const padding: LongDoctorOrderEntry[] = Array.from(
     { length: Math.max(0, MIN_ROWS - list.length) },
     () => ({
@@ -144,15 +172,48 @@ const paddedEntries = computed<LongDoctorOrderEntry[]>(() => {
       remark: ''
     })
   )
-  return [...list, ...padding]
+  const all = [...list, ...padding]
+
+  const timeKey = (val?: any) => {
+    if (val === null || val === undefined || val === '') return ''
+    const { date, time } = formatDateTimeSplit(val)
+    return date && time ? `${date} ${time}` : date
+  }
+
+  const calcSpan = (getter: (e: LongDoctorOrderEntry) => string) =>
+    all.map((row, i) => {
+      const key = getter(row)
+      if (!key) return 1
+      if (i > 0 && getter(all[i - 1]) === key) return 0
+      let span = 1
+      while (i + span < all.length && getter(all[i + span]) === key) span += 1
+      return span
+    })
+
+  const startSpans = calcSpan((e) => timeKey(e.orderTime))
+  const stopSpans = calcSpan((e) => timeKey(e.stopTime))
+
+  return all.map((row, i) => ({
+    ...row,
+    _startSpan: startSpans[i],
+    _stopSpan: stopSpans[i]
+  }))
 })
 
 /**
- * 标准格式化时间为 YYYY-MM-DD HH:mm
- * - 字符串日期直接匹配;如已是标准格式则原样返回,否则尽量截前 16 位
- * - 数字时间戳按本地时区解析
- * - 空值/无效返回空串
+ * 拼接纸质单“长期医嘱”栏右侧的用法信息:途径 + 给药时间 + 频次(缩写),
+ * 如“静脉滴注 立即执行 tid”;无值的要素不显示。
  */
+const buildMeta = (item: LongDoctorOrderEntry): string => {
+  const parts: string[] = []
+  const route = getMedCn(MED_ROUTE_OPTIONS, item.route)
+  const medTime = getMedCn(MED_TIME_OPTIONS, item.medTime)
+  if (route) parts.push(route)
+  if (medTime) parts.push(medTime)
+  if (item.frequency) parts.push(item.frequency)
+  return parts.join(' ')
+}
+
 /**
  * 把时间拆成 { date: 'YYYY-MM-DD', time: 'HH:mm' } 两段用于上下两行展示
  * - 空值返回 { date:'', time:'' }
@@ -167,40 +228,15 @@ const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
     const d = new Date(val)
     if (isNaN(d.getTime())) return { date: '', time: '' }
     const pad = (n: number) => String(n).padStart(2, '0')
-    str = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+    str = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
+      d.getHours()
+    )}:${pad(d.getMinutes())}`
   }
   const m = str.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
   if (m) return { date: m[1], time: m[2] }
   return { date: str, time: '' }
 }
 
-const renderDateTime = (val?: any) => {
-  const { date, time } = formatDateTimeSplit(val)
-  if (!date && !time) return ''
-  if (!time) return date
-  return `${date} ${time}`
-}
-
-/**
- * 拆成两个 cell-value(上行日期、下行时间),便于 CSS 用 block 布局上下两行
- */
-const formatFullTime = (val?: any): string => {
-  if (val === null || val === undefined || val === '') return ''
-  if (typeof val === 'string') {
-    const m = val.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/)
-    if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
-    // 兜底:返回原字符串(不再做任何神秘切分)
-    return val
-  }
-  if (typeof val === 'number') {
-    const d = new Date(val)
-    if (isNaN(d.getTime())) return ''
-    const pad = (n: number) => String(n).padStart(2, '0')
-    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
-  }
-  return ''
-}
-
 /** 打开打印(详情页) */
 const open = async (row: any = {}) => {
   dialogVisible.value = true
@@ -235,34 +271,50 @@ const print = {
 
 <style lang="scss" scoped>
 .long-order-print {
-  padding: 4px 0; /* 左右 padding 去掉,让容器贴满 dialog */
+  padding: 4px 0;
   color: #000;
   background: #fff;
-  /* 屏幕预览弹填 dialog(width:100%),左侧距 dialog 内壁 4px,右贴边。
-     打印时 @page A4 portrait 纸张可用 ~194mm,容器按其自动调整。 */
   width: 100%;
   margin: 0;
   box-sizing: border-box;
+  font-family: 'SimSun', '宋体', 'Songti SC', serif;
 
   .doc-title {
     text-align: center;
-    font-size: 20px;
+    font-size: 28px;
     font-weight: bold;
-    margin: 4px 0 12px;
+    letter-spacing: 10px;
+    text-indent: 10px; // 抵消末字间距,保持视觉居中
+    margin: 6px 0 14px;
   }
 
+  /* 基本信息:纸质单风格,值带下划线 */
   .info-row {
     display: flex;
-    justify-content: space-between;
-    margin-bottom: 8px;
-    font-size: 13px;
+    align-items: flex-end;
+    margin-bottom: 10px;
+    font-size: 14px;
+    padding: 0 2px;
+
+    .info-item {
+      display: inline-flex;
+      align-items: flex-end;
+      margin-right: 22px;
+      white-space: nowrap;
 
-    .info-cell {
-      flex: 1;
-      min-width: 0;
-      padding: 0 8px;
       .lbl {
-        font-weight: bold;
+        line-height: 1.6;
+      }
+      .val {
+        display: inline-block;
+        min-width: 70px;
+        line-height: 1.6;
+        text-align: center;
+        border-bottom: 1px solid #000;
+        padding: 0 6px;
+      }
+      .val.bed {
+        min-width: 130px;
       }
     }
   }
@@ -271,12 +323,13 @@ const print = {
     width: 100%;
     border-collapse: collapse;
     table-layout: fixed;
-    font-size: 12px;
+    font-size: 13px;
+    border: 2px solid #000;
 
     th,
     td {
-      border: 1px solid #555;
-      padding: 4px 4px;
+      border: 1px solid #000;
+      padding: 4px 6px;
       text-align: center;
       vertical-align: middle;
       word-break: break-all;
@@ -285,34 +338,73 @@ const print = {
     thead th {
       background: #fff;
       font-weight: bold;
+      padding: 5px 4px;
+    }
+    .group-head {
+      font-size: 16px;
+      letter-spacing: 2px;
+    }
+    .order-head {
+      font-size: 20px;
+      letter-spacing: 8px;
+      text-indent: 8px;
+      padding: 6px 4px;
+    }
+    .sub-head {
+      font-size: 14px;
     }
 
     tbody td {
-      height: 28px;
+      height: 34px;
     }
-    .cell-value {
-      display: inline-block;
-      width: 100%;
-      text-align: center;
-    }
-    .cell-value.pre {
-      white-space: pre-wrap;
-      text-align: left;
-      word-break: break-word;
+    /* 明细行之间用虚线,模拟纸质单据行线 */
+    tbody tr td {
+      border-bottom: 1px dashed #555;
     }
-    /* 日期 + 时间上下两行展示(医嘱时间/停止时间列) */
-    .datetime {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      line-height: 1.2;
-      font-size: 11px;
+
+    .time-cell {
+      .datetime {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        line-height: 1.35;
+        font-size: 12px;
+      }
     }
-    .datetime .d-line {
-      font-weight: 500;
+    .sign-cell {
+      font-size: 13px;
     }
-    .datetime .t-line {
-      color: #444;
+    .content-cell {
+      padding: 4px 8px;
+
+      .oe-line {
+        display: flex;
+        align-items: baseline;
+        gap: 8px;
+      }
+      .oe-no {
+        font-weight: bold;
+        flex: 0 0 auto;
+      }
+      .oe-text {
+        flex: 1 1 auto;
+        text-align: left;
+        white-space: pre-wrap;
+        word-break: break-word;
+        line-height: 1.5;
+      }
+      .oe-meta {
+        flex: 0 0 auto;
+        white-space: nowrap;
+        letter-spacing: 1px;
+      }
+      .oe-remark {
+        margin-top: 2px;
+        padding-left: 20px;
+        text-align: left;
+        font-size: 11px;
+        color: #444;
+      }
     }
   }
 }
@@ -320,10 +412,9 @@ const print = {
 
 <!--
   @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
-  A4 竖版 210×297mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
-  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  A4 竖版 210×297mm:
+  - thead { display: table-header-group }:浏览器自动在每页重复两行表头
   - tr { page-break-inside: avoid }:单条医嘱行不被切断
-  - 表格整体允许跨页(默认行为)
 -->
 <style lang="scss">
 @media print {

+ 14 - 43
src/views/operation-analysis/overview/campus-compare/index.vue

@@ -91,25 +91,20 @@
       <div class="panel-title">
         院区经营指标明细
         <span class="title-tip">
-          床位为楼栋房源实时统计;在院人数为长者档案口径,两数差异通常为请假在外等情况
+          数据来自运营概况聚合接口;在住床位为床位口径,在院人数为长者人员口径,两数可能存在差异
         </span>
       </div>
       <el-table :data="tableData" v-loading="loading" border stripe>
         <el-table-column label="院区" prop="campusName" min-width="170" fixed="left" />
-        <el-table-column label="楼栋数" prop="buildCount" width="80" align="center" />
         <el-table-column label="总床位(张)" prop="totalBed" width="150" align="right" sortable />
         <el-table-column label="在住床位(张)" prop="usedBed" width="150" align="right" sortable />
         <el-table-column
           label="在院人数(人)"
+          prop="inHospitalCount"
           width="150"
           align="right"
           sortable
-          :sort-by="'inHospital'"
-        >
-          <template #default="{ row }">
-            {{ row.inHospital === null ? '-' : row.inHospital }}
-          </template>
-        </el-table-column>
+        />
         <el-table-column
           label="空闲床位(张)"
           prop="unusedBed"
@@ -139,10 +134,7 @@
         </el-table-column>
         <el-table-column label="状态" width="120" align="center">
           <template #default="{ row }">
-            <el-tag v-if="row.failed > 0" type="warning" size="small">
-              {{ row.failed }} 栋统计失败
-            </el-tag>
-            <el-tag v-else-if="row.totalBed === 0" type="info" size="small">未配置房源</el-tag>
+            <el-tag v-if="row.totalBed === 0" type="info" size="small">未配置房源</el-tag>
             <el-tag v-else :type="rateTagType(row.occupancyRate)" size="small">
               {{ rateText(row.occupancyRate) }}
             </el-tag>
@@ -155,36 +147,20 @@
 
 <script lang="ts" setup>
 import Echart from '@/components/Echart/src/Echart.vue'
-import {
-  fetchCampusBedStats,
-  fetchCampusInHospitalMap,
-  getCampusOptions,
-  type CampusBedStat
-} from '../campus-data'
+import { fetchOverviewDynamics, getCampusOptions, type CampusBedStat } from '../campus-data'
 
 defineOptions({ name: 'OaOverviewCampusCompare' })
 
-interface CampusTableRow extends CampusBedStat {
-  /** 在院人数(长者档案人员口径,查询失败为 null) */
-  inHospital: number | null
-}
-
 const message = useMessage()
 
 const loading = ref(false)
 const campusOptions = getCampusOptions()
 const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
 const stats = ref<CampusBedStat[]>([])
-const inHospitalMap = ref<Map<number, number | null>>(new Map())
 
 /** 明细表数据:入住率降序 */
-const tableData = computed<CampusTableRow[]>(() =>
-  [...stats.value]
-    .map((item) => ({
-      ...item,
-      inHospital: inHospitalMap.value.get(item.tenantId) ?? null
-    }))
-    .sort((a, b) => (b.occupancyRate ?? -1) - (a.occupancyRate ?? -1))
+const tableData = computed<CampusBedStat[]>(() =>
+  [...stats.value].sort((a, b) => (b.occupancyRate ?? -1) - (a.occupancyRate ?? -1))
 )
 
 const summary = computed(() => {
@@ -220,7 +196,7 @@ const compareChartOption = computed<any>(() => {
           (p) => `${p.marker}${p.seriesName}:${p.value}${p.seriesName === '入住率' ? '%' : ' 张'}`
         )
         const extra = campus
-          ? `<br/>空闲床位:${campus.unusedBed} 张<br/>楼栋数:${campus.buildCount} 栋`
+          ? `<br/>空闲床位:${campus.unusedBed} 张<br/>在院人数:${campus.inHospitalCount} 人`
           : ''
         return `${params[0].axisValue}${extra}<br/>${lines.join('<br/>')}`
       }
@@ -348,18 +324,13 @@ const loadData = async () => {
   }
   loading.value = true
   try {
-    const [bedStats, hospitalMap] = await Promise.all([
-      fetchCampusBedStats(selectedCampusIds.value),
-      fetchCampusInHospitalMap(selectedCampusIds.value)
-    ])
-    stats.value = bedStats
-    inHospitalMap.value = hospitalMap
-    const failedCount = stats.value.reduce((sum, item) => sum + item.failed, 0)
-    if (failedCount > 0) {
-      message.warning(`${failedCount} 栋楼统计数据获取失败,已按 0 计入,请稍后刷新重试`)
+    const dyn = await fetchOverviewDynamics(selectedCampusIds.value)
+    if (dyn) {
+      stats.value = dyn.campusStats
+    } else {
+      stats.value = []
+      message.error('院区对比数据加载失败,请稍后重试')
     }
-  } catch (e) {
-    message.error('院区对比数据加载失败,请稍后重试')
   } finally {
     loading.value = false
   }

+ 104 - 177
src/views/operation-analysis/overview/campus-data.ts

@@ -1,7 +1,4 @@
 import request from '@/config/axios'
-import { getBuildList, getBuildData } from '@/api/system/badManage'
-import { getElderlyPage, getWeeklyOperationSummary } from '@/api/elderly/elder/elderly-Info'
-import { getTenantId } from '@/utils/auth'
 import { useUserStore } from '@/store/modules/user'
 
 export interface CampusOption {
@@ -12,28 +9,14 @@ export interface CampusOption {
 export interface CampusBedStat {
   tenantId: number
   campusName: string
-  buildCount: number
   totalBed: number
   usedBed: number
   unusedBed: number
   tempBed: number
+  /** 当前在住人数(periodEndCount,长者人员口径,区别于在住床位) */
+  inHospitalCount: number
   /** 入住率(0-100),未配置床位时为 null */
   occupancyRate: number | null
-  /** 楼栋统计接口调用失败的数量 */
-  failed: number
-}
-
-interface BuildItem {
-  id: number
-  buildName: string
-  tenantId: number
-}
-
-interface BuildData {
-  totalBedCount?: number
-  usedBedCount?: number
-  unusedBedCount?: number
-  temporaryOccupationCount?: number
 }
 
 /**
@@ -56,102 +39,7 @@ export const getCampusOptions = (): CampusOption[] => {
 }
 
 /**
- * 限制并发数执行任务
- */
-const runWithConcurrency = async <T, R>(
-  items: T[],
-  limit: number,
-  task: (item: T, index: number) => Promise<R>
-): Promise<R[]> => {
-  const results: R[] = new Array(items.length)
-  let cursor = 0
-  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
-    while (cursor < items.length) {
-      const index = cursor
-      cursor += 1
-      results[index] = await task(items[index], index)
-    }
-  })
-  await Promise.all(workers)
-  return results
-}
-
-/**
- * 聚合各院区的楼栋床位实时数据:
- * 1. 一次查询全部楼栋(后端可能返回所选院区之外的数据,这里按 tenantIds 过滤)
- * 2. 并发查询每栋楼的床位统计(getBuildData)
- * 3. 按院区汇总
- */
-export const fetchCampusBedStats = async (tenantIds: number[]): Promise<CampusBedStat[]> => {
-  const nameMap = new Map<number, string>()
-  getCampusOptions().forEach((item) => nameMap.set(item.value, item.label))
-
-  const buildList: BuildItem[] = ((await getBuildList({ tenantIds, orgType: 1 })) || []).filter(
-    (item: BuildItem) => tenantIds.includes(item.tenantId)
-  )
-
-  const buildDataList = await runWithConcurrency(buildList, 6, async (build) => {
-    try {
-      const data: BuildData = await getBuildData(build.id)
-      return { tenantId: build.tenantId, data: data || {}, failed: false }
-    } catch (e) {
-      return { tenantId: build.tenantId, data: {} as BuildData, failed: true }
-    }
-  })
-
-  const aggMap = new Map<
-    number,
-    {
-      buildCount: number
-      totalBed: number
-      usedBed: number
-      unusedBed: number
-      tempBed: number
-      failed: number
-    }
-  >()
-
-  buildList.forEach((build, index) => {
-    const stat = buildDataList[index]
-    if (!aggMap.has(build.tenantId)) {
-      aggMap.set(build.tenantId, {
-        buildCount: 0,
-        totalBed: 0,
-        usedBed: 0,
-        unusedBed: 0,
-        tempBed: 0,
-        failed: 0
-      })
-    }
-    const agg = aggMap.get(build.tenantId)!
-    agg.buildCount += 1
-    agg.totalBed += Number(stat.data.totalBedCount) || 0
-    agg.usedBed += Number(stat.data.usedBedCount) || 0
-    agg.unusedBed += Number(stat.data.unusedBedCount) || 0
-    agg.tempBed += Number(stat.data.temporaryOccupationCount) || 0
-    if (stat.failed) agg.failed += 1
-  })
-
-  return tenantIds.map((tenantId) => {
-    const agg = aggMap.get(tenantId) || {
-      buildCount: 0,
-      totalBed: 0,
-      usedBed: 0,
-      unusedBed: 0,
-      tempBed: 0,
-      failed: 0
-    }
-    return {
-      tenantId,
-      campusName: nameMap.get(tenantId) || `院区${tenantId}`,
-      ...agg,
-      occupancyRate: agg.totalBed > 0 ? Math.round((agg.usedBed / agg.totalBed) * 1000) / 10 : null
-    }
-  })
-}
-
-/**
- * 护理等级分布项(看板接口返回的原始结构)
+ * 护理等级分布项
  */
 export interface NameCountItem {
   name: string
@@ -159,7 +47,7 @@ export interface NameCountItem {
 }
 
 /**
- * 将看板接口中各院区自定义的护理等级名称按同名合并,返回降序排列结果
+ * 将各院区自定义的护理等级名称按同名合并,返回降序排列结果
  */
 export const mergeNameCount = (list: NameCountItem[]): NameCountItem[] => {
   const map = new Map<string, number>()
@@ -200,63 +88,57 @@ const buildRecentMonths = (months: number): MonthCountItem[] => {
   return list
 }
 
-/**
- * 在院人数:长者档案在院状态(inStatus=1)总数,按所选院区过滤
- */
-export const fetchInHospitalCount = async (tenantIds: number[]): Promise<number | null> => {
-  try {
-    const res: any = await getElderlyPage({
-      tenantIds,
-      inStatus: 1,
-      pageNo: 1,
-      pageSize: 1,
-      orgType: 1
-    })
-    return Number(res?.total) || 0
-  } catch {
-    return null
-  }
-}
-
-/**
- * 逐院区查询在院人数(长者档案人员口径,区别于楼栋"在住床位"口径)
- * 返回 tenantId -> 在院人数(查询失败院区为 null)
- */
-export const fetchCampusInHospitalMap = async (
-  tenantIds: number[]
-): Promise<Map<number, number | null>> => {
-  const values = await runWithConcurrency(tenantIds, 6, async (id) => fetchInHospitalCount([id]))
-  const map = new Map<number, number | null>()
-  tenantIds.forEach((id, index) => map.set(id, values[index]))
-  return map
-}
-
-/**
- * 是否为"只查看登录机构自身"的单院区场景。
- * 运营周期汇总接口 getWeeklyOperationSummary 只按当前登录机构返回:
- * 机构账号查看自身时口径正确可直接使用;集团账号(getTenantId 为集团 id)不满足。
- */
-const isOwnSingleCampus = (tenantIds: number[]): boolean => {
-  if (!tenantIds || tenantIds.length !== 1) return false
-  const loginTenantId = Number(getTenantId())
-  return !!loginTenantId && loginTenantId === tenantIds[0]
+/** 运营周期(周/月)汇总,多院区已在前端按机构求和 */
+export interface OverviewWeekly {
+  /** 本周开始日期(上周六) */
+  periodBegin: string
+  /** 本周结束日期(本周五) */
+  periodEnd: string
+  /** 本周期初在院人数 */
+  periodBeginCount: number
+  /** 本周期末在院人数(当前在住人数) */
+  periodEndCount: number
+  weeklyCheckInCount: number
+  weeklyCheckOutCount: number
+  weeklyNetGrowthCount: number
+  monthlyCheckInCount: number
+  monthlyCheckOutCount: number
+  monthlyNetGrowthCount: number
+  /** 期末空余床位数(总床位 - 在住床位) */
+  periodEndEmptyBedCount: number
 }
 
 /** 后端 dynamics 接口单个机构的原始返回 */
 interface OverviewDynamicsCampusVO {
   tenantId: number
-  tenantName: string
+  tenantName?: string
   newCheckInCount?: number
   checkOutCount?: number
   askLeaveCount?: number
   todayAskLeaveCount?: number
   waitingCount?: number
   reservationCount?: number
+  periodBegin?: string
+  periodEnd?: string
+  periodBeginCount?: number
+  weeklyCheckInCount?: number
+  weeklyCheckOutCount?: number
+  periodEndCount?: number
+  weeklyNetGrowthCount?: number
+  monthlyCheckInCount?: number
+  monthlyCheckOutCount?: number
+  monthlyNetGrowthCount?: number
+  periodEndEmptyBedCount?: number
+  totalBedCount?: number
+  occupiedBedCount?: number
+  occupancyRate?: string
+  emptyBedCount?: number
+  temporaryOccupationCount?: number
   retreatTrend?: { month: string; count: number }[]
   nurseLevelDistribution?: { nurseLevelId?: number; nurseLevelName?: string; count?: number }[]
 }
 
-/** 运营总览动态指标(多院区已在前端聚合求和) */
+/** 运营总览聚合数据(KPI + 床位 + 周期 + 趋势 + 护理等级,多院区已聚合) */
 export interface OverviewDynamics {
   /** 今日实际完成入住办理人数 */
   todayCheckInCount: number
@@ -270,18 +152,35 @@ export interface OverviewDynamics {
   waitingCount: number
   /** 预订在途人数 */
   reservationCount: number
+  /** 当前在住人数(各机构 periodEndCount 求和,长者人员口径) */
+  inHospitalCount: number
+  /** 各院区床位 + 在院明细(按入参数组顺序,缺数据院区补 0) */
+  campusStats: CampusBedStat[]
+  /** 运营周期(周/月)汇总 */
+  weekly: OverviewWeekly
   /** 近 12 个月已完成退住趋势(多院区按月求和) */
   checkoutTrend: MonthCountItem[]
   /** 在院长者护理等级分布(跨院区按同名合并,降序) */
   nursingLevels: NameCountItem[]
-  /** 运营周期(周/月)汇总;仅登录机构查看自身时有值,多院区待后端扩展 */
-  weekly: Record<string, any> | null
+}
+
+const sumBy = (list: OverviewDynamicsCampusVO[], key: keyof OverviewDynamicsCampusVO) =>
+  list.reduce((acc, item) => acc + (Number(item[key]) || 0), 0)
+
+/** 后端返回的入住率为字符串(如 "75.84%"),解析为数值 */
+const parseRate = (rate?: string): number | null => {
+  if (rate === null || rate === undefined || rate === '') return null
+  const value = parseFloat(String(rate).replace('%', ''))
+  return Number.isNaN(value) ? null : value
 }
 
 /**
- * 运营总览动态指标聚合:GET /operation-analysis/overview/dynamics
- * 后端按机构分组返回(每机构一项),这里做跨院区聚合:
+ * 运营总览全量聚合:GET /operation-analysis/overview/dynamics
+ * 后端按机构分组返回(每机构一项),一次返回:
+ * 今日动态(新收/退院/请假/当天请假/轮候/预订)、床位统计、运营周期汇总、
+ * 近 12 个月退住趋势、护理等级分布。这里做跨院区聚合:
  * - 数值指标求和;
+ * - 各院区床位明细直接透传,入住率采用后端字符串解析值;
  * - retreatTrend 按月求和并按近 12 个月骨架零填充;
  * - nurseLevelDistribution 按护理等级**名称**跨院区同名合并
  *   (各院区等级字典独立、id 不通用,实测同名"一级护理"存在多个不同 id)。
@@ -300,8 +199,25 @@ export const fetchOverviewDynamics = async (
     })
     if (!Array.isArray(list)) return null
 
-    const sum = (key: keyof OverviewDynamicsCampusVO) =>
-      list.reduce((acc, item) => acc + (Number(item[key]) || 0), 0)
+    const dataMap = new Map(list.map((item) => [item.tenantId, item]))
+    const nameMap = new Map<number, string>()
+    getCampusOptions().forEach((item) => nameMap.set(item.value, item.label))
+
+    // 各院区床位 + 在院明细
+    const campusStats: CampusBedStat[] = tenantIds.map((tenantId) => {
+      const item = dataMap.get(tenantId)
+      const totalBed = Number(item?.totalBedCount) || 0
+      return {
+        tenantId,
+        campusName: item?.tenantName || nameMap.get(tenantId) || `院区${tenantId}`,
+        totalBed,
+        usedBed: Number(item?.occupiedBedCount) || 0,
+        unusedBed: Number(item?.emptyBedCount) || 0,
+        tempBed: Number(item?.temporaryOccupationCount) || 0,
+        inHospitalCount: Number(item?.periodEndCount) || 0,
+        occupancyRate: totalBed > 0 ? parseRate(item?.occupancyRate) : null
+      }
+    })
 
     // 退住趋势:按月合并
     const trend = buildRecentMonths(12)
@@ -323,23 +239,34 @@ export const fetchOverviewDynamics = async (
       )
     )
 
-    // 运营周期汇总:仅单机构自身场景取现网接口;多院区后端尚未提供
-    let weekly: Record<string, any> | null = null
-    if (isOwnSingleCampus(tenantIds)) {
-      const weeklyRes: any = await getWeeklyOperationSummary()
-      weekly = weeklyRes?.data ?? weeklyRes ?? null
+    // 运营周期汇总:各机构周期日期一致,数值求和
+    const firstWithPeriod = list.find((item) => item.periodBegin && item.periodEnd)
+    const weekly: OverviewWeekly = {
+      periodBegin: firstWithPeriod?.periodBegin || '',
+      periodEnd: firstWithPeriod?.periodEnd || '',
+      periodBeginCount: sumBy(list, 'periodBeginCount'),
+      periodEndCount: sumBy(list, 'periodEndCount'),
+      weeklyCheckInCount: sumBy(list, 'weeklyCheckInCount'),
+      weeklyCheckOutCount: sumBy(list, 'weeklyCheckOutCount'),
+      weeklyNetGrowthCount: sumBy(list, 'weeklyNetGrowthCount'),
+      monthlyCheckInCount: sumBy(list, 'monthlyCheckInCount'),
+      monthlyCheckOutCount: sumBy(list, 'monthlyCheckOutCount'),
+      monthlyNetGrowthCount: sumBy(list, 'monthlyNetGrowthCount'),
+      periodEndEmptyBedCount: sumBy(list, 'periodEndEmptyBedCount')
     }
 
     return {
-      todayCheckInCount: sum('newCheckInCount'),
-      todayCheckOutCount: sum('checkOutCount'),
-      leaveOutCount: sum('askLeaveCount'),
-      todayLeaveCount: sum('todayAskLeaveCount'),
-      waitingCount: sum('waitingCount'),
-      reservationCount: sum('reservationCount'),
+      todayCheckInCount: sumBy(list, 'newCheckInCount'),
+      todayCheckOutCount: sumBy(list, 'checkOutCount'),
+      leaveOutCount: sumBy(list, 'askLeaveCount'),
+      todayLeaveCount: sumBy(list, 'todayAskLeaveCount'),
+      waitingCount: sumBy(list, 'waitingCount'),
+      reservationCount: sumBy(list, 'reservationCount'),
+      inHospitalCount: weekly.periodEndCount,
+      campusStats,
+      weekly,
       checkoutTrend: trend,
-      nursingLevels,
-      weekly
+      nursingLevels
     }
   } catch {
     return null

+ 20 - 29
src/views/operation-analysis/overview/dashboard/index.vue

@@ -12,14 +12,14 @@
         </div>
       </div>
 
-      <!-- 动态指标加载失败提示 -->
+      <!-- 聚合数据加载失败提示 -->
       <el-alert
         v-if="!dynamicsReady"
         type="warning"
         :closable="false"
         show-icon
         class="api-tip"
-        title="今日新收、今日退院、请假人数、当天请假、轮候待住、预订在途、退住趋势及护理等级分布加载失败,请稍后刷新重试;床位与在院人数不受影响。"
+        title="运营总览数据加载失败,请稍后刷新重试"
       />
 
       <!-- 实时动态 KPI -->
@@ -43,15 +43,16 @@
         <div class="period-head">
           <span class="period-title">
             <Icon icon="ep:calendar" />
-            运营周期汇总<template v-if="weekly.institutionName"
-              >({{ weekly.institutionName }})</template
-            >
+            运营周期汇总
+          </span>
+          <span class="period-tip">
+            <template v-if="hasWeekly">
+              周期 {{ weekly.periodBegin }} 至 {{ weekly.periodEnd }},按所选院区合计口径统计
+            </template>
+            <template v-else>按所选院区合计口径统计</template>
           </span>
-          <span class="period-tip">按所选院区合计口径统计</span>
-        </div>
-        <div v-if="!hasWeekly" class="period-empty">
-          暂无数据:多院区运营周期汇总需后端在聚合接口中补充,当前仅登录机构查看本院时展示
         </div>
+        <div v-if="!hasWeekly" class="period-empty">暂无数据:运营周期汇总加载失败</div>
         <el-row v-else :gutter="10">
           <el-col :xs="8" :sm="8" :md="4" :lg="4">
             <div class="period-item">
@@ -175,13 +176,12 @@
 import { Refresh } from '@element-plus/icons-vue'
 import Echart from '@/components/Echart/src/Echart.vue'
 import {
-  fetchCampusBedStats,
-  fetchInHospitalCount,
   fetchOverviewDynamics,
   getCampusOptions,
   type CampusBedStat,
   type NameCountItem,
-  type OverviewDynamics
+  type OverviewDynamics,
+  type OverviewWeekly
 } from '../campus-data'
 
 defineOptions({ name: 'OaOverviewDashboard' })
@@ -191,19 +191,17 @@ const message = useMessage()
 const loading = ref(false)
 const updateTime = ref('')
 
-/** 在院人数(档案在院状态) */
-const inHospitalCount = ref<number | null>(null)
-/** 后端聚合动态指标(新收/退院/请假/轮候/预订/退住趋势/护理等级/周期汇总),加载失败为 null */
+/** 运营总览聚合数据(动态/床位/周期/趋势/护理等级),加载失败为 null */
 const dynamics = ref<OverviewDynamics | null>(null)
-const campusStats = ref<CampusBedStat[]>([])
 
 const campusOptions = getCampusOptions()
 
 const dynamicsReady = computed(() => dynamics.value !== null)
-const hasWeekly = computed(() => dynamics.value?.weekly != null)
-const weekly = computed<Record<string, any>>(() => dynamics.value?.weekly ?? {})
+const hasWeekly = computed(() => dynamicsReady.value)
+const weekly = computed<OverviewWeekly | Record<string, never>>(() => dynamics.value?.weekly ?? {})
 const careLevels = computed<NameCountItem[]>(() => dynamics.value?.nursingLevels ?? [])
 const checkoutTrend = computed(() => dynamics.value?.checkoutTrend ?? [])
+const campusStats = computed<CampusBedStat[]>(() => dynamics.value?.campusStats ?? [])
 
 const fmtCount = (val: number | null | undefined) => (val === null || val === undefined ? '-' : val)
 
@@ -217,7 +215,7 @@ const kpiCards = computed(() => [
   },
   {
     label: '在院人数',
-    value: fmtCount(inHospitalCount.value),
+    value: fmtCount(dynamics.value?.inHospitalCount),
     unit: '人',
     icon: 'ep:user',
     theme: 'green'
@@ -306,7 +304,7 @@ const campusChartOption = computed<any>(() => {
           (p) => `${p.marker}${p.seriesName}:${p.value}${p.seriesName === '入住率' ? '%' : ' 张'}`
         )
         const extra = campus
-          ? `<br/>空闲床位:${campus.unusedBed} 张<br/>楼栋数:${campus.buildCount} 栋`
+          ? `<br/>空闲床位:${campus.unusedBed} 张<br/>在院人数:${campus.inHospitalCount} 人`
           : ''
         return `${params[0].axisValue}${extra}<br/>${lines.join('<br/>')}`
       }
@@ -541,17 +539,10 @@ const loadAll = async () => {
   loading.value = true
   try {
     const tenantIds = campusOptions.map((item) => item.value)
-    const [bedStats, inHospital, dyn] = await Promise.all([
-      fetchCampusBedStats(tenantIds),
-      fetchInHospitalCount(tenantIds),
-      fetchOverviewDynamics(tenantIds)
-    ])
-
-    campusStats.value = bedStats
-    inHospitalCount.value = inHospital
+    const dyn = await fetchOverviewDynamics(tenantIds)
     dynamics.value = dyn
     if (!dyn) {
-      message.warning('今日动态、退住趋势及护理等级数据加载失败,床位与在院人数为实时统计数据')
+      message.error('运营总览数据加载失败,请稍后刷新重试')
     }
     updateTime.value = formatTime(new Date())
   } catch (e) {

+ 101 - 0
src/views/operation-analysis/overview/运营总览-后端待补充接口清单.md

@@ -0,0 +1,101 @@
+# 运营总览 — 二期规划数据清单(供后端排期参考)
+
+> 提出方:前端 | 最近更新:2026-09-17 | 环境:生产库 `http://47.107.245.0:48080`
+> 说明:驾驶舱全部指标(总床位/在住/空闲/临时占用/入住率、在院人数、
+> 今日新收/退院/请假/当天请假/轮候/预订、运营周期汇总、
+> 近 12 个月退住趋势、护理等级分布)**均已由
+> `GET /operation-analysis/overview/dynamics` 一个接口提供并对接完成**
+> (2026-09-17 集团 15 院区 + 单机构实测通过),一期无数据缺口。
+>
+> 以下 2 块为**二期规划图表 / 模块**所需数据,当前页面暂无对应入口,
+> 非一期缺漏,不急于本期提供,列出供后端评估排期与库表可回溯性。
+
+---
+
+## 二期 1:历史"入住趋势"及历史月末在院 / 入住率
+
+> 用途:计划在驾驶舱新增"经营趋势"图(每月入住 / 退住 / 净增长 /
+> 月末在院人数 / 月末入住率);当前页面仅有"近 12 个月已完成退住趋势",
+> 该图不在一期范围内。
+
+### 需要的数据
+
+近 N 个月(默认 12)按月的:
+
+| 指标 | 说明 |
+| --- | --- |
+| 当月入住人数 | 当月完成入住办理人数,口径同 dynamics.newCheckInCount |
+| 当月净增长 | = 当月入住 - 当月退住(退住数接口已有,仅缺入住数) |
+| 月末在院人数 | 月末日终在院人数(inStatus=1) |
+| 月末入住率 | 月末在住床位 / 总床位 |
+
+> 注:当月**退住**人数已由 dynamics.retreatTrend 提供,本次只缺**入住侧**及
+> 月末时点数据。
+
+### 背景(现有接口为何不支持)
+
+- 入住趋势:现网 `bpm/process-instance/check-in-page` 在多院区查询或带
+  `checkInTime` 参数时均返回 500,无法按月统计;
+- 历史月末在院 / 入住率:`build/getBuildFloorStatistics` 持续返回
+  `code=500 系统异常`,无任何历史快照数据源。
+
+### 期望
+
+新接口 `GET /operation-analysis/overview/check-trend`,参数
+`tenantIds: Long[]`(可选,空 = 账号可见全部)、`months: Integer`(默认 12)、
+`orgType=1`;返回按月升序数组,每项字段:`month`(yyyy-MM)、`checkInCount`、
+`checkOutCount`、`netGrowth`、`monthEndInHospital`、`monthEndOccupancyRate`。
+历史月末入住率若库表无法回溯,历史月份返回 null 即可(前端显示"-"),
+上线当月起保证准确。
+
+---
+
+## 二期 2:合同到期预警(按院区批量统计)
+
+> 用途:计划在驾驶舱 / 经营管理模块新增合同到期预警卡(30/60/90 天
+> 到期人数、已过期未续签人数),提前安排续签、防止断收;
+> 当前页面暂无该模块。
+
+### 需要的数据
+
+所选院区范围内,未来 30 / 60 / 90 天合同到期的在院长者人数,以及已过期未续签人数。
+
+### 背景(现有接口为何不支持)
+
+现网 `GET /elderly/expenseOrder/checkContractNearlyExpire` 必须传单个 `elderId`
+(不传返回 `400 请求参数缺失:elderId`),是长者个人维度的逐条校验接口,
+无法按院区批量得到汇总人数,页面上该预警指标**无数据**。
+
+### 期望
+
+新接口 `GET /operation-analysis/overview/contract-expiry`,参数
+`tenantIds: Long[]`(可选)、`days: Integer[]`(默认 `[30,60,90]`,按自然日)、
+`orgType=1`;返回每个时间档一项:`days`、`expiringCount`(未来 days 天内
+含今天合同到期的在院长者数,多份合同取最近到期日)、`expiredCount`
+(已过期未续签的在院长者数)。口径:仅统计在院(`inStatus=1`),
+试住 / 已退住不纳入。
+
+---
+
+## 汇总(二期规划,非一期缺口)
+
+| # | 规划数据 | 规划场景 | 建议提供方式 |
+| --- | --- | --- | --- |
+| 1 | 历史月度入住趋势、月末在院人数、月末入住率 | 驾驶舱"经营趋势"图(二期) | 新增 check-trend 接口 |
+| 2 | 合同到期 30/60/90 天预警人数、已过期人数 | 驾驶舱 / 经营管理预警模块(二期) | 新增 contract-expiry 接口 |
+
+---
+
+## 附:已解决记录(2026-09-17)
+
+- ✅ **多院区运营周期汇总**:后端已在 dynamics 每个机构项内补充
+  periodBegin/periodEnd、periodBeginCount、periodEndCount、
+  weekly/monthly 入住退住净增、periodEndEmptyBedCount,
+  前端按机构求和后展示(15 院区实测:期初 3005、期末 3016、
+  本周 16/5/+11、本月 56/25/+31、空床 1260)。
+- ✅ **各院区床位统计**:后端已在 dynamics 每个机构项内补充
+  totalBedCount、occupiedBedCount、emptyBedCount、
+  temporaryOccupationCount、occupancyRate(字符串百分比),
+  前端不再逐楼栋调用(原 1 次 getList + 约 45 次 getBuildData 已全部去掉)。
+  另发现颐年五心养老院(tenantId=197)新口径床位 570/在住 551,
+  比楼栋接口(594/561)少 24 张/10 人,疑为停用床位口径差异,请后端知悉确认。

+ 46 - 27
src/views/operation-analysis/overview/运营总览-后端接口需求.md

@@ -25,21 +25,35 @@
 - 数组参数序列化形式:`tenantIds[0]=196&tenantIds[1]=197`(项目 axios 已用 qs allowDots);
 - 返回结构沿用 `{ code: 0, data: ..., msg: '' }`。
 
-前端已用**现有接口**实现、无需新开发的指标(2026-09-16 实测通过):
-总床位/在住床位/空闲/临时占用(楼栋统计聚合)、在院人数与各院区在院人数(`elderlyInfo/findPage`
-`inStatus=1`)。
-**今日新收/退院/请假/当天请假/轮候/预订/退住趋势/护理等级**已全部切换到 V1 聚合接口
-`GET /operation-analysis/overview/dynamics`(单机构与多院区统一走该接口,详见第二节)。
-**运营周期汇总**目前仅机构账号自身可查(复用现网 `getWeeklyOperationSummary`),
-多院区版仍待后端在该接口 V2 补充。
+驾驶舱与多院区对比页的全部展示指标,截至 2026-09-17 已由
+`GET /operation-analysis/overview/dynamics`(V2)**一个接口**提供并完成对接:
+总床位/在住床位/空闲/临时占用/入住率、在院人数、今日新收/退院/请假/当天请假/
+轮候/预订、运营周期汇总(周/月)、近 12 个月退住趋势、护理等级分布。
+此前使用的楼栋逐楼统计、`findPage` 在院数、`getWeeklyOperationSummary`
+等拼装方式已全部废弃。
 
 ---
 
-## 二、接口 1:运营总览动态指标聚合(✅ V1 已上线 2026-09-16)
+## 二、接口 1:运营总览动态指标聚合(✅ V2 已上线 2026-09-17,驾驶舱/对比页全部数据已对接)
 
 ### `GET /operation-analysis/overview/dynamics`
 
-**按机构分组返回数组**,每个机构一项;前端始终显式传入选区 `tenantIds` 并做跨机构聚合。
+**按机构分组返回数组**,每个机构一项,一次返回全部数据:今日动态、床位统计、
+运营周期汇总、近 12 个月退住趋势、护理等级分布。前端始终显式传入选区
+`tenantIds` 并做跨机构聚合(数值求和、趋势按月合并、护理按名称合并、
+入住率前端按 sum(在住)/sum(总床位) 重算,不直接相加百分比字符串)。
+驾驶舱原先的 1 次 getList + 约 45 次 getBuildData、findPage 在院数、
+getWeeklyOperationSummary 已全部废弃,两个页面现各只调这一个接口。
+
+> V2(2026-09-17)相比 V1 新增字段:
+> 周期 `periodBegin/periodEnd/periodBeginCount/periodEndCount/
+> weeklyCheckInCount/weeklyCheckOutCount/weeklyNetGrowthCount/
+> monthlyCheckInCount/monthlyCheckOutCount/monthlyNetGrowthCount/
+> periodEndEmptyBedCount`;
+> 床位 `totalBedCount/occupiedBedCount/occupancyRate(字符串如"75.84%")/
+> emptyBedCount/temporaryOccupationCount`。
+> 注意 `periodEndCount`(当前在住人数,人员口径)与 `occupiedBedCount`
+> (在住床位,床位口径)部分院区不相等(如沙市 50 vs 87),前端分别展示。
 
 > ⚠️ 实测注意(2026-09-16):**不传 `tenantIds` 时返回 29 家机构**,大于该账号菜单
 > 可见的 15 家(含 212/213/215/217/218/221/222/223/224/226/227 等)。
@@ -102,22 +116,17 @@
 | 预订在途 | reservationCount | 旧看板无 |
 | 退住趋势 | retreatTrend | 旧看板无(前端曾逐月查询,已废弃) |
 | 护理等级 | nurseLevelDistribution | nursingSituation(旧接口跨院区不合并) |
-| 总床位 / 在院人数 | 不含(仍走楼栋统计 / findPage) | hospitalDynamics[总床位数/在院人数] |
+| 总床位 / 在住床位 / 空闲 / 临时 / 入住率 | totalBedCount / occupiedBedCount / emptyBedCount / temporaryOccupationCount / occupancyRate | 楼栋逐楼统计(已废弃) |
+| 当前在住人数(在院人数) | periodEndCount | hospitalDynamics[在院人数] / findPage(已废弃) |
+| 运营周期汇总(周/月) | periodBegin/periodEnd 及 weekly/monthly 系列字段 | getWeeklyOperationSummary(已废弃) |
 
-### 仍缺:运营周期汇总(请在 V2 补充)
+### ✅ 运营周期汇总(V2 已提供,2026-09-17 对接完成)
 
-V1 **未包含** `weeklyOperation`。多院区场景下驾驶舱"运营周期汇总"卡片继续显示空态;
-机构账号查看自身时,前端另调现网 `getWeeklyOperationSummary`(本就按登录机构返回)展示。
-请在该接口每个机构项内(或新增汇总字段)补充以下字段,前端即可开放多院区周期卡:
-
-| 字段 | 类型 | 说明 |
-| --- | --- | --- |
-| institutionName | String | 范围名称 |
-| periodBegin / periodEnd | String | 周期起止 yyyy-MM-dd(建议新增) |
-| periodBeginCount / periodEndCount | Integer | 周期初/末在院人数 |
-| weeklyCheckInCount / weeklyCheckOutCount / weeklyNetGrowthCount | Integer | 本周入住/退住/净增 |
-| monthlyCheckInCount / monthlyCheckOutCount / monthlyNetGrowthCount | Integer | 本月入住/退住/净增 |
-| periodEndEmptyBedCount | Integer | 期末空余床位数(与床位统计合计一致) |
+每个机构项内直接返回周期字段,前端按机构求和展示;15 院区实测:
+periodBegin=2026-09-12、periodEnd=2026-09-18(15 家一致)、
+periodBeginCount 合计 3005、periodEndCount 合计 3016、
+本周入住/退住/净增 16/5/+11、本月 56/25/+31、periodEndEmptyBedCount 1260。
+单机构(209)实测 722/723、3/2/+1、13/5/+8、560,与登录机构旧周汇总口径一致。
 
 ### 现网旧接口原始抓包(2026-09-15 集团账号,供对照)
 
@@ -127,13 +136,23 @@ V1 **未包含** `weeklyOperation`。多院区场景下驾驶舱"运营周期汇
 
 ---
 
-## 三、【建议】接口 2:各院区床位统计聚合(性能优化)
+## 三、~~【建议】接口 2:各院区床位统计聚合~~(✅ 已由 dynamics V2 解决,2026-09-17)
+
+> 后端已在 `GET /operation-analysis/overview/dynamics` 每个机构项内直接返回
+> totalBedCount / occupiedBedCount / emptyBedCount / temporaryOccupationCount /
+> occupancyRate(字符串百分比),前端逐楼调用已全部移除(原 getList 1 次 +
+> getBuildData 约 45 次 → 0 次),无需再开新接口。
+> 以下原始建议仅存档。
+>
+> ⚠️ 一处请后端知悉确认:颐年五心养老院(tenantId=197)dynamics V2 床位为
+> 570 总床 / 551 在住,比楼栋接口(594/561)少 24 张 / 10 人,
+> 疑为停用床位/房间是否计入的口径差异,前端已统一以 dynamics 为准。
 
-### `GET /operation-analysis/overview/campus-bed-stats`
+### `GET /operation-analysis/overview/campus-bed-stats`(已不需要)
 
-当前前端为得到各院区床位数据,需先调 `build/getList`(约 75 栋楼),
+~~当前前端为得到各院区床位数据,需先调 `build/getList`(约 75 栋楼),
 再对每栋楼并发调 `build/getBuildData`(75 次请求,限并发 6)。
-功能可用但请求数多、耗时长,建议后端提供一次聚合。
+功能可用但请求数多、耗时长,建议后端提供一次聚合。~~
 
 ### 请求参数