|
| 1 | +# 設計書: ノード時間使用量レポート |
| 2 | + |
| 3 | +## 概要 |
| 4 | + |
| 5 | +result_server Flaskアプリケーションに、アプリケーション(`code`)×システム(`system`)のクロス集計テーブルでノード時間使用量を表示する管理者専用ページを追加する。 |
| 6 | + |
| 7 | +既存の `received/` ディレクトリ内のResult JSONファイルを読み込み、ファイル名のタイムスタンプから会計年度・期間を判定し、`execution_mode` に応じた計算式でノード時間を算出する。集計期間は月次・半期・年度の3種類を切り替え可能とする。 |
| 8 | + |
| 9 | +既存のアーキテクチャパターン(Blueprint、`admin_required` デコレータ、`_results_base.html` テンプレート継承、`utils/` モジュール分離)に準拠して実装する。 |
| 10 | + |
| 11 | +## アーキテクチャ |
| 12 | + |
| 13 | +```mermaid |
| 14 | +graph TD |
| 15 | + A[ブラウザ] -->|GET /results/usage| B[usage Blueprint] |
| 16 | + B -->|admin_required| C[admin権限チェック] |
| 17 | + C -->|403/redirect| A |
| 18 | + C -->|OK| D[usage_report ルート関数] |
| 19 | + D -->|period_type, fiscal_year| E[Node_Hours_Aggregator] |
| 20 | + E -->|JSONファイル読み込み| F[received/ ディレクトリ] |
| 21 | + E -->|集計結果| D |
| 22 | + D -->|render_template| G[usage_report.html] |
| 23 | + G -->|extends| H[_results_base.html] |
| 24 | +``` |
| 25 | + |
| 26 | +新規ページは既存の `results_bp` Blueprint内に `/usage` ルートとして追加する。新規Blueprintは作成しない。これは `/results/usage` というURLパスが要件で指定されており、`results_bp` が `/results` プレフィックスで登録されているためである。 |
| 27 | + |
| 28 | +集計ロジックは `utils/node_hours.py` に分離し、ルート関数はリクエストパラメータの取得とテンプレートレンダリングのみを担当する。 |
| 29 | + |
| 30 | +## コンポーネントとインターフェース |
| 31 | + |
| 32 | +### 1. ルート: `results.usage_report` |
| 33 | + |
| 34 | +ファイル: `result_server/routes/results.py` に追加 |
| 35 | + |
| 36 | +```python |
| 37 | +@results_bp.route("/usage", methods=["GET"]) |
| 38 | +@admin_required # routes/admin.py から import |
| 39 | +def usage_report(): |
| 40 | + """ノード時間使用量レポートページ""" |
| 41 | + ... |
| 42 | +``` |
| 43 | + |
| 44 | +- クエリパラメータ: `period_type`(monthly/semi_annual/fiscal_year)、`fiscal_year`(整数) |
| 45 | +- `admin_required` デコレータで認証・権限チェック(既存の `routes/admin.py` から import) |
| 46 | +- `Node_Hours_Aggregator` を呼び出して集計結果を取得 |
| 47 | +- テンプレート `usage_report.html` をレンダリング |
| 48 | + |
| 49 | +### 2. 集計モジュール: `utils/node_hours.py` |
| 50 | + |
| 51 | +新規ファイル。以下の関数を提供する。 |
| 52 | + |
| 53 | +```python |
| 54 | +def compute_node_hours(data: dict) -> float: |
| 55 | + """ |
| 56 | + 単一Result JSONからノード時間を算出する。 |
| 57 | + |
| 58 | + - cross: node_count × run_time / 3600 |
| 59 | + - native: node_count × (build_time + run_time) / 3600 |
| 60 | + - node_count/run_time が欠損・非数値の場合は 0.0 |
| 61 | + - native で build_time が欠損・非数値の場合は build_time=0 として算出 |
| 62 | + |
| 63 | + Returns: float(小数点以下2桁に丸め) |
| 64 | + """ |
| 65 | + |
| 66 | +def extract_timestamp_from_filename(filename: str) -> datetime | None: |
| 67 | + """ |
| 68 | + ファイル名から YYYYMMDD_HHMMSS パターンのタイムスタンプを抽出する。 |
| 69 | + 既存の results_loader.py と同じ正規表現パターンを使用。 |
| 70 | + """ |
| 71 | + |
| 72 | +def get_fiscal_year(dt: datetime) -> int: |
| 73 | + """ |
| 74 | + 日付から会計年度を返す。 |
| 75 | + 1〜3月 → 前年の会計年度、4〜12月 → その年の会計年度 |
| 76 | + """ |
| 77 | + |
| 78 | +def get_fiscal_month_index(dt: datetime) -> int: |
| 79 | + """ |
| 80 | + 会計年度内の月インデックス(0〜11)を返す。 |
| 81 | + 4月=0, 5月=1, ..., 3月=11 |
| 82 | + """ |
| 83 | + |
| 84 | +def get_half(dt: datetime) -> str: |
| 85 | + """ |
| 86 | + 上期(first)か下期(second)かを返す。 |
| 87 | + 4〜9月=first, 10〜3月=second |
| 88 | + """ |
| 89 | + |
| 90 | +def aggregate_node_hours( |
| 91 | + directory: str, |
| 92 | + fiscal_year: int, |
| 93 | + period_type: str, # "monthly" | "semi_annual" | "fiscal_year" |
| 94 | +) -> dict: |
| 95 | + """ |
| 96 | + 指定ディレクトリの全JSONファイルを読み込み、ノード時間をクロス集計する。 |
| 97 | + confidentialフィルタなし(admin専用ページのため全データ対象)。 |
| 98 | + |
| 99 | + Returns: { |
| 100 | + "apps": [...], # ソート済みApp名リスト |
| 101 | + "systems": [...], # ソート済みSystem名リスト |
| 102 | + "periods": [...], # 期間ラベルリスト |
| 103 | + "table": { # {app: {system: {period: float}}} |
| 104 | + "AppA": { |
| 105 | + "SystemX": {"2025年4月": 1.23, ...}, |
| 106 | + ... |
| 107 | + }, |
| 108 | + ... |
| 109 | + }, |
| 110 | + "row_totals": {app: {period: float}}, |
| 111 | + "col_totals": {system: {period: float}}, |
| 112 | + "grand_totals": {period: float}, |
| 113 | + "available_fiscal_years": [2024, 2025, ...], |
| 114 | + } |
| 115 | + """ |
| 116 | +``` |
| 117 | + |
| 118 | +### 3. テンプレート: `usage_report.html` |
| 119 | + |
| 120 | +ファイル: `result_server/templates/usage_report.html` |
| 121 | + |
| 122 | +- `_results_base.html` を継承(ナビゲーション、共通CSS含む) |
| 123 | +- 期間タイプ切り替えUI(ボタングループ or セレクト) |
| 124 | +- 会計年度ドロップダウン |
| 125 | +- クロス集計テーブル(行=App、列=System×期間、行合計、列合計、総合計) |
| 126 | +- データなし時のメッセージ表示 |
| 127 | + |
| 128 | +### 4. ナビゲーション更新: `_navigation.html` |
| 129 | + |
| 130 | +admin権限ユーザーにのみ「📈 Usage」リンクを表示する。既存の admin ドロップダウン内ではなく、認証済みユーザー向けのナビゲーションリンク行に追加する。 |
| 131 | + |
| 132 | +```html |
| 133 | +{% if 'admin' in session.get('user_affiliations', []) %} |
| 134 | +<a href="{{ url_for('results.usage_report') }}" class="nav-link ...">📈 Usage</a> |
| 135 | +{% endif %} |
| 136 | +``` |
| 137 | + |
| 138 | +## データモデル |
| 139 | + |
| 140 | +### Result JSON 構造(集計に使用するフィールド) |
| 141 | + |
| 142 | +```json |
| 143 | +{ |
| 144 | + "code": "string", // アプリケーション名 |
| 145 | + "system": "string", // システム名 |
| 146 | + "node_count": "number|string", // ノード数(文字列の場合あり) |
| 147 | + "execution_mode": "string", // "cross" | "native" | null |
| 148 | + "pipeline_timing": { |
| 149 | + "build_time": "number", // ビルド時間(秒) |
| 150 | + "run_time": "number" // 実行時間(秒) |
| 151 | + }, |
| 152 | + "confidential": "any" // 機密タグ(集計では無視) |
| 153 | +} |
| 154 | +``` |
| 155 | + |
| 156 | +### ファイル名パターン |
| 157 | + |
| 158 | +``` |
| 159 | +result_YYYYMMDD_HHMMSS_{uuid}.json |
| 160 | +padata_YYYYMMDD_HHMMSS_{uuid}.json |
| 161 | +``` |
| 162 | + |
| 163 | +タイムスタンプ部分(`YYYYMMDD_HHMMSS`)を正規表現 `\d{8}_\d{6}` で抽出し、`datetime.strptime` でパースする。既存の `results_loader.py` と同一のパターンを使用する。 |
| 164 | + |
| 165 | +### 集計データ構造 |
| 166 | + |
| 167 | +```python |
| 168 | +# aggregate_node_hours の戻り値 |
| 169 | +{ |
| 170 | + "apps": ["AppA", "AppB"], # アルファベット順 |
| 171 | + "systems": ["SystemX", "SystemY"], # アルファベット順 |
| 172 | + "periods": ["2025年4月", "2025年5月", ...], # 期間ラベル |
| 173 | + "table": { |
| 174 | + "AppA": { |
| 175 | + "SystemX": {"2025年4月": 1.23, "2025年5月": 0.0}, |
| 176 | + "SystemY": {"2025年4月": 0.45, "2025年5月": 2.10}, |
| 177 | + }, |
| 178 | + }, |
| 179 | + "row_totals": {"AppA": {"2025年4月": 1.68, ...}}, |
| 180 | + "col_totals": {"SystemX": {"2025年4月": 1.23, ...}}, |
| 181 | + "grand_totals": {"2025年4月": 1.68, ...}, |
| 182 | + "available_fiscal_years": [2024, 2025], |
| 183 | +} |
| 184 | +``` |
| 185 | + |
| 186 | +### 期間ラベル生成ルール |
| 187 | + |
| 188 | +| period_type | ラベル例 | |
| 189 | +|---|---| |
| 190 | +| monthly | `2025年4月`, `2025年5月`, ..., `2026年3月` | |
| 191 | +| semi_annual | `上期(4月〜9月)`, `下期(10月〜3月)` | |
| 192 | +| fiscal_year | `FY2025` | |
| 193 | + |
| 194 | + |
| 195 | +## 正当性プロパティ(Correctness Properties) |
| 196 | + |
| 197 | +*プロパティとは、システムの全ての有効な実行において成立すべき特性や振る舞いのことである。人間が読める仕様と機械的に検証可能な正当性保証の橋渡しとなる形式的な記述である。* |
| 198 | + |
| 199 | +### Property 1: ノード時間計算の正当性 |
| 200 | + |
| 201 | +*任意の* 有効な `node_count`(正の数値)、`run_time`(非負の数値)、`build_time`(非負の数値)、および `execution_mode` に対して、`compute_node_hours` は以下を返すべきである: |
| 202 | +- `execution_mode` が `cross` の場合: `round(node_count × run_time / 3600, 2)` |
| 203 | +- `execution_mode` が `native` の場合: `round(node_count × (build_time + run_time) / 3600, 2)` |
| 204 | +- `node_count` または `run_time` が欠損・非数値の場合: `0.0` |
| 205 | +- `native` モードで `build_time` が欠損・非数値の場合: `round(node_count × run_time / 3600, 2)` |
| 206 | + |
| 207 | +**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5** |
| 208 | + |
| 209 | +### Property 2: 小数点以下2桁の丸め |
| 210 | + |
| 211 | +*任意の* Result JSONデータに対して、`compute_node_hours` の戻り値は小数点以下2桁以内の精度を持つべきである(`value == round(value, 2)` が成立する)。 |
| 212 | + |
| 213 | +**Validates: Requirements 2.6** |
| 214 | + |
| 215 | +### Property 3: 会計年度分類の正当性 |
| 216 | + |
| 217 | +*任意の* 有効な日付に対して、`get_fiscal_year` は以下を返すべきである: |
| 218 | +- 月が1〜3月の場合: `year - 1` |
| 219 | +- 月が4〜12月の場合: `year` |
| 220 | + |
| 221 | +**Validates: Requirements 4.3, 4.4** |
| 222 | + |
| 223 | +### Property 4: セル集計値の正当性 |
| 224 | + |
| 225 | +*任意の* Result JSONファイル集合に対して、集計テーブルの各セル `table[app][system][period]` の値は、該当する `app`、`system`、`period` に一致する全レコードの `compute_node_hours` の合計値と等しくなるべきである。 |
| 226 | + |
| 227 | +**Validates: Requirements 5.2, 7.3** |
| 228 | + |
| 229 | +### Property 5: 合計値の整合性 |
| 230 | + |
| 231 | +*任意の* 集計結果に対して: |
| 232 | +- 各Appの行合計 = 全Systemのセル値の合計 |
| 233 | +- 各Systemの列合計 = 全Appのセル値の合計 |
| 234 | +- 総合計 = 全行合計の合計 = 全列合計の合計 |
| 235 | + |
| 236 | +**Validates: Requirements 5.3, 5.4, 5.5** |
| 237 | + |
| 238 | +### Property 6: アルファベット順ソート |
| 239 | + |
| 240 | +*任意の* 集計結果に対して、`apps` リストと `systems` リストはそれぞれアルファベット昇順にソートされているべきである。 |
| 241 | + |
| 242 | +**Validates: Requirements 5.7, 5.8** |
| 243 | + |
| 244 | +### Property 7: 期間タイプ別の期間数 |
| 245 | + |
| 246 | +*任意の* 会計年度に対して: |
| 247 | +- `period_type` が `monthly` の場合: 期間ラベルは正確に12個 |
| 248 | +- `period_type` が `semi_annual` の場合: 期間ラベルは正確に2個 |
| 249 | +- `period_type` が `fiscal_year` の場合: 期間ラベルは正確に1個 |
| 250 | + |
| 251 | +**Validates: Requirements 7.1, 8.1, 9.1** |
| 252 | + |
| 253 | +### Property 8: 機密データの包含 |
| 254 | + |
| 255 | +*任意の* `confidential` タグを持つResult JSONファイルに対して、`aggregate_node_hours` はそのファイルのノード時間を集計結果に含めるべきである。 |
| 256 | + |
| 257 | +**Validates: Requirements 6.1** |
| 258 | + |
| 259 | +### Property 9: 月次ラベルフォーマット |
| 260 | + |
| 261 | +*任意の* 会計年度に対して、`monthly` 期間タイプの期間ラベルは全て `YYYY年M月` 形式に一致し、4月から翌年3月まで順序通りに並ぶべきである。 |
| 262 | + |
| 263 | +**Validates: Requirements 7.2** |
| 264 | + |
| 265 | +## エラーハンドリング |
| 266 | + |
| 267 | +| エラー状況 | 対応 | |
| 268 | +|---|---| |
| 269 | +| 未認証アクセス | ログインページへリダイレクト(`admin_required` デコレータ) | |
| 270 | +| 非admin認証済みアクセス | 403 Forbidden(`admin_required` デコレータ) | |
| 271 | +| JSONファイル読み込みエラー | 該当ファイルをスキップ(既存パターンに準拠) | |
| 272 | +| `node_count` 欠損・非数値 | ノード時間 = 0.0 | |
| 273 | +| `run_time` 欠損・非数値 | ノード時間 = 0.0 | |
| 274 | +| `build_time` 欠損・非数値(native) | build_time = 0 として算出 | |
| 275 | +| ファイル名にタイムスタンプなし | 該当ファイルをスキップ | |
| 276 | +| 指定期間にデータなし | 「該当期間のデータがありません」メッセージ表示 | |
| 277 | +| 不正な `period_type` パラメータ | デフォルト値 `fiscal_year` にフォールバック | |
| 278 | +| 不正な `fiscal_year` パラメータ | 現在の会計年度にフォールバック | |
| 279 | + |
| 280 | +## テスト戦略 |
| 281 | + |
| 282 | +### テストアプローチ |
| 283 | + |
| 284 | +ユニットテストとプロパティベーステストの二本立てで網羅的にテストする。 |
| 285 | + |
| 286 | +- **ユニットテスト**: 具体的な入出力例、エッジケース、エラー条件の検証 |
| 287 | +- **プロパティベーステスト**: ランダム生成された入力に対する普遍的な性質の検証 |
| 288 | + |
| 289 | +### プロパティベーステスト |
| 290 | + |
| 291 | +ライブラリ: **Hypothesis**(既存プロジェクトで使用済み) |
| 292 | + |
| 293 | +各プロパティテストは最低100回のイテレーションで実行する。各テストにはデザインドキュメントのプロパティ番号をコメントで参照する。 |
| 294 | + |
| 295 | +タグフォーマット: `Feature: node-hours-usage-report, Property {number}: {property_text}` |
| 296 | + |
| 297 | +各正当性プロパティは単一のプロパティベーステストで実装する。 |
| 298 | + |
| 299 | +### ユニットテスト |
| 300 | + |
| 301 | +以下のケースをユニットテストでカバーする: |
| 302 | + |
| 303 | +- ルートのアクセス制御(未認証→リダイレクト、非admin→403、admin→200) |
| 304 | +- ナビゲーションリンクの表示条件 |
| 305 | +- デフォルトパラメータ(period_type=fiscal_year、現在の会計年度) |
| 306 | +- データなし時のメッセージ表示 |
| 307 | +- 半期ラベル・年度ラベルのフォーマット |
| 308 | +- 期間タイプ切り替えUIの存在確認 |
| 309 | + |
| 310 | +### テストファイル構成 |
| 311 | + |
| 312 | +``` |
| 313 | +result_server/tests/ |
| 314 | +├── test_node_hours.py # compute_node_hours, get_fiscal_year 等のユニットテスト |
| 315 | +├── test_node_hours_properties.py # プロパティベーステスト(Property 1〜9) |
| 316 | +└── test_usage_route.py # ルート・テンプレートのユニットテスト |
| 317 | +``` |
0 commit comments