# 通知設計書 18-在庫期限切れ間近通知

## 概要

本ドキュメントは、InvenTreeシステムにおける「在庫期限切れ間近通知」の設計仕様を定義する。在庫アイテムの有効期限が近づいた際に、購読者へ通知を送信する機能について詳述する。

### 本通知の処理概要

在庫期限切れ間近通知（Stale Stock Notification）は、在庫アイテムの有効期限（expiry_date）がSTOCK_STALE_DAYS設定で指定された日数以内に到来する場合に、パーツの購読者に自動的に通知を送信するスケジュールタスク機能である。

**業務上の目的・背景**：有効期限のある在庫（消耗品、化学薬品、食品原料など）を扱う業務において、期限切れ在庫の発生は廃棄コストの増加や品質問題につながる。期限切れ間近の在庫を早期に通知することで、担当者は優先的な使用、割引販売、適切な廃棄処理などの対応を行うことができる。

**通知の送信タイミング**：`check_stale_stock`スケジュールタスクが毎日実行され、有効期限がSTOCK_STALE_DAYS以内の在庫アイテムを検出する。ユーザーごとに該当する在庫アイテムを集約し、1通の統合通知として送信する。

**通知の受信者**：期限切れ間近の在庫アイテムに関連するパーツの購読者が受信対象となる。各ユーザーは購読しているパーツの在庫のみを受け取る。

**通知内容の概要**：期限切れ間近の在庫アイテムのリスト（パーツ名、ロケーション、数量、バッチ番号、シリアル番号、有効期限、残日数）を1通のメールに統合して通知する。

**期待されるアクション**：受信者は期限切れ間近の在庫を確認し、製造指示での優先使用、在庫の移動・販売、廃棄処理の手配などの対応を行う。

## 通知種別

- メール通知（InvenTreeEmailNotifications経由）
- UI通知（InvenTreeUINotifications経由）
- Slack通知（InvenTreeSlackNotifications経由、設定時）

## 送信仕様

### 基本情報

| 項目 | 内容 |
|-----|------|
| 送信方式 | バッチ処理（スケジュールタスク） |
| 実行スケジュール | 毎日（DAILY） |
| 統合通知 | ユーザーごとに1通に統合 |
| 優先度 | 中 |
| リトライ | 無（通知送信失敗時はログ記録のみ） |

### 送信先決定ロジック

1. 期限切れ間近の各在庫アイテムに対して、パーツの購読者（part.get_subscribers()）を取得
2. ユーザーごとに該当する在庫アイテムをグループ化
3. 各ユーザーに対して、まとめて1通の通知を送信

## 通知テンプレート

### メール通知の場合

| 項目 | 内容 |
|-----|------|
| 送信元アドレス | システム設定による |
| 送信元名称 | [INVENTREE_INSTANCE] |
| 件名 | Stale stock notification |
| 形式 | HTML |

### 本文テンプレート

```html
{% extends "email/email.html" %}

{% block header %}
<tr style='background: #eef3f7; height: 4rem; text-align: center;'>
    <td colspan="7" style="padding-bottom: 1rem; color: #68686a; font-weight: bold;">
        <p style='font-size: 1.25rem;'>{{ message }}</p>
        <p>The following stock items are approaching their expiry dates:</p>
    </td>
</tr>
{% endblock header %}

{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid #68686a; background-color: #f5f5f5;">
    <td style="text-align: center; padding: 10px; font-weight: bold;">Part</td>
    <td style="text-align: center; padding: 10px; font-weight: bold;">Location</td>
    <td style="text-align: center; padding: 10px; font-weight: bold;">Quantity</td>
    <td style="text-align: center; padding: 10px; font-weight: bold;">Batch</td>
    <td style="text-align: center; padding: 10px; font-weight: bold;">Serial Number</td>
    <td style="text-align: center; padding: 10px; font-weight: bold;">Expiry Date</td>
    <td style="text-align: center; padding: 10px; font-weight: bold;">Days Until Expiry</td>
</tr>
{% for item_data in stale_items %}
<tr style="height: 3rem; border-bottom: 1px solid #ddd;">
    <td style="text-align: center; padding: 10px;">
        <a href="{{ item_data.absolute_url }}" style="color: #007bff; text-decoration: none;">
            {{ item_data.stock_item.part.full_name }}
        </a>
    </td>
    <td style="text-align: center; padding: 10px;">{{ item_data.stock_item.location|default:"-" }}</td>
    <td style="text-align: center; padding: 10px;">{{ item_data.stock_item.quantity }}</td>
    <td style="text-align: center; padding: 10px;">{{ item_data.stock_item.batch|default:"-" }}</td>
    <td style="text-align: center; padding: 10px;">{{ item_data.stock_item.serial|default:"-" }}</td>
    <td style="text-align: center; padding: 10px;">{{ item_data.stock_item.expiry_date|date:"Y-m-d"|default:"-" }}</td>
    <td style="text-align: center; padding: 10px;">
        {% if item_data.days_until_expiry < 0 %}
            <span style="color: red; font-weight: bold;">{{ item_data.expiry_status }}</span>
        {% elif item_data.days_until_expiry == 0 %}
            <span style="color: orange; font-weight: bold;">{{ item_data.expiry_status }}</span>
        {% else %}
            {{ item_data.expiry_status }}
        {% endif %}
    </td>
</tr>
{% endfor %}
{% endblock body %}
```

### 添付ファイル

| ファイル名 | 形式 | 条件 | 説明 |
|----------|------|------|------|
| なし | - | - | 添付ファイルなし |

## テンプレート変数

| 変数名 | 説明 | データ取得元 | 必須 |
|--------|------|-------------|-----|
| stale_items | 期限切れ間近の在庫アイテムリスト | 計算結果 | Yes |
| item_count | 在庫アイテム数 | len(stale_items) | Yes |
| name | 通知名（"Stale stock notification"） | 定数 | Yes |
| message | 通知メッセージ | "You have {count} stock items approaching their expiry dates" | Yes |
| template | メールテンプレート情報 | {'html': 'email/stale_stock_notification.html', 'subject': name} | Yes |

### stale_items配列の各要素

| 変数名 | 説明 | データ取得元 |
|--------|------|-------------|
| stock_item | 在庫アイテムオブジェクト | StockItem instance |
| absolute_url | 在庫アイテム詳細ページへのリンク | construct_absolute_url() |
| days_until_expiry | 有効期限までの残日数 | (expiry_date - today).days |
| expiry_status | 有効期限ステータス文字列 | "X days" / "Expires today" / "Expired X days ago" |

## 送信トリガー・条件

### トリガー一覧

| トリガー種別 | トリガーイベント | 送信条件 | 説明 |
|------------|----------------|---------|------|
| スケジュール | check_stale_stock | STOCK_ENABLE_EXPIRY=True AND STOCK_STALE_DAYS > 0 | 毎日実行のバッチ処理 |

### 送信抑止条件

| 条件 | 説明 |
|-----|------|
| 有効期限機能が無効 | STOCK_ENABLE_EXPIRY=False の場合は処理終了 |
| STOCK_STALE_DAYS未設定 | STOCK_STALE_DAYS <= 0 の場合は処理終了 |
| 該当在庫なし | 期限切れ間近の在庫がない場合は通知なし |
| 購読者なし | 該当パーツに購読者がいない場合は通知なし |

## 処理フロー

### 送信フロー

```mermaid
flowchart TD
    A[スケジュールタスク実行] --> B[check_stale_stock]
    B --> C{STOCK_ENABLE_EXPIRY?}
    C -->|No| D[終了]
    C -->|Yes| E{STOCK_STALE_DAYS > 0?}
    E -->|No| D
    E -->|Yes| F[stale_threshold計算]
    F --> G[期限切れ間近在庫検索]
    G --> H{該当在庫あり?}
    H -->|No| D
    H -->|Yes| I[ユーザーごとにグループ化]
    I --> J[各ユーザーに対してループ]
    J --> K[offload_task: notify_stale_stock]
    K --> L[次のユーザーへ]
    L --> J
    L --> D
```

## データベース参照・更新仕様

### 参照テーブル一覧

| テーブル名 | 用途 | 備考 |
|-----------|------|------|
| stock_stockitem | 期限切れ間近在庫の検索 | expiry_date条件 |
| part_part | パーツ情報 | 購読者取得用 |
| stock_stocklocation | ロケーション情報 | 表示用 |
| auth_user | ユーザー情報 | 送信先ユーザー |
| common_inventreesetting | グローバル設定 | STOCK_ENABLE_EXPIRY、STOCK_STALE_DAYS |

### テーブル別参照項目詳細

#### stock_stockitem

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| expiry_date | 有効期限 | < stale_threshold |
| part | パーツ参照 | 購読者取得用 |
| location | ロケーション | 表示用 |
| quantity | 数量 | 表示用 |
| batch | バッチ番号 | 表示用 |
| serial | シリアル番号 | 表示用 |

### 更新テーブル一覧

| テーブル名 | 操作 | 概要 |
|-----------|------|------|
| common_notificationentry | INSERT/UPDATE | 通知送信記録 |
| common_notificationmessage | INSERT | UI通知メッセージ |

#### 送信ログテーブル

| 操作 | 項目（カラム名） | 更新値 | 備考 |
|-----|-----------------|-------|------|
| INSERT/UPDATE | key | 'stock.notify_stale_stock' | 通知カテゴリ |
| INSERT/UPDATE | uid | StockItem.pk（最初のアイテム） | 対象オブジェクトID |
| INSERT/UPDATE | updated | 現在日時 | 最終送信日時 |

## エラー処理

### エラーケース一覧

| エラー種別 | 発生条件 | 対処方法 |
|----------|---------|---------|
| 送信失敗 | メール送信エラー | ログ記録、次のユーザーへ処理続行 |
| テンプレートエラー | テンプレート変数不正 | ログ記録、処理続行 |

### リトライ仕様

| 項目 | 内容 |
|-----|------|
| リトライ回数 | 0（リトライなし） |
| リトライ間隔 | - |
| リトライ対象エラー | - |

## 配信設定

### レート制限

| 項目 | 内容 |
|-----|------|
| 1分あたり上限 | 制限なし |
| 1日あたり上限 | 同一ユーザーに対し1日1回（check_recent=False により統合通知は毎日送信可能） |

### 配信時間帯

スケジュールタスクの実行時間に依存

## セキュリティ考慮事項

- 通知を受信できるのは、該当するパーツに購読しているユーザーのみ
- メール本文には在庫アイテムの詳細（パーツ名、ロケーション、数量、有効期限など）を含む
- NOTIFY_BY_EMAILユーザー設定によりメール通知の受信可否を制御可能

## 備考

- ユーザーごとに統合通知を送信することで、メール数を削減
- check_recent=Falseにより、毎日の統合通知を可能にしている
- 有効期限の色分け表示（赤：期限切れ、オレンジ：当日、通常：期限前）

---

## コードリーディングガイド

本通知を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: スケジュールタスクの登録を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | tasks.py | `src/backend/InvenTree/part/tasks.py` | @scheduled_task(ScheduledTask.DAILY)デコレータ（行158-159） |

#### Step 2: エントリーポイントを理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | tasks.py | `src/backend/InvenTree/part/tasks.py` | check_stale_stock()（行158-225） |
| 2-2 | tasks.py | `src/backend/InvenTree/part/tasks.py` | notify_stale_stock()（行58-131） |

**主要処理フロー**:

**check_stale_stock():**
1. **行172-175**: STOCK_ENABLE_EXPIRY設定チェック
2. **行177-180**: STOCK_STALE_DAYS設定チェック
3. **行184-185**: stale_threshold計算（today + stale_days）
4. **行188-192**: 期限切れ間近在庫検索（IN_STOCK_FILTER、expiry_date < threshold）
5. **行194-196**: 該当なしの場合は終了
6. **行201-210**: ユーザーごとにグループ化
7. **行213-221**: 各ユーザーに対してnotify_stale_stockをオフロード

**notify_stale_stock():**
1. **行70-71**: 空リストの場合は終了
2. **行73-79**: 通知名・メッセージ設定（単数/複数対応）
3. **行82-111**: 各在庫アイテムの詳細情報構築（absolute_url、days_until_expiry、expiry_status）
4. **行113-119**: コンテキスト構築
5. **行125-131**: trigger_notification呼び出し（check_recent=False）

#### Step 3: 残日数計算を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | tasks.py | `src/backend/InvenTree/part/tasks.py` | days_until_expiry計算（行87-101） |

### プログラム呼び出し階層図

```
[Django-Q スケジューラ]
    │
    └─ check_stale_stock() [part/tasks.py:158]
           │
           ├─ get_global_setting('STOCK_ENABLE_EXPIRY')
           │
           ├─ get_global_setting('STOCK_STALE_DAYS')
           │
           ├─ stale_threshold = today + timedelta(days=stale_days)
           │
           ├─ StockItem.objects.filter(
           │      IN_STOCK_FILTER,
           │      expiry_date__isnull=False,
           │      expiry_date__lt=stale_threshold
           │  )
           │
           ├─ user_stale_items = {} ユーザーごとにグループ化
           │      │
           │      └─ for stock_item in stale_stock_items:
           │             part.get_subscribers() → user_stale_items[user].append()
           │
           └─ for user, items in user_stale_items.items():
                  │
                  └─ offload_task(notify_stale_stock, user, items)
                         │
                         └─ notify_stale_stock(user, stale_items) [part/tasks.py:58]
                                │
                                ├─ stale_items_enhanced構築
                                │      ├─ absolute_url
                                │      ├─ days_until_expiry
                                │      └─ expiry_status
                                │
                                └─ trigger_notification(check_recent=False)
```

### データフロー図

```
[入力]                    [処理]                         [出力]

スケジュール実行 ───▶ check_stale_stock() ───▶ 期限切れ間近在庫検索
                              │
                              ├─ ユーザーごとにグループ化
                              │
                              └─ notify_stale_stock()
                                        │
                                        ├─ 詳細情報構築
                                        │      ├─ days_until_expiry
                                        │      └─ expiry_status
                                        │
                                        └─ trigger_notification ───▶ 統合通知送信
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| tasks.py | `src/backend/InvenTree/part/tasks.py` | ソース | スケジュールタスク、通知送信実装 |
| models.py | `src/backend/InvenTree/stock/models.py` | ソース | StockItem、IN_STOCK_FILTER |
| notifications.py | `src/backend/InvenTree/common/notifications.py` | ソース | trigger_notification関数 |
| stale_stock_notification.html | `src/backend/InvenTree/templates/email/stale_stock_notification.html` | テンプレート | メール本文テンプレート |
