備品管理システム仕様書
1. システム概要
1.1 目的
本システムは、会津大学学園祭(通称: 蒼翔祭、略称: 学祭)実行委員会(学祭委員) における備品管理の中核を担うシステムである。
学祭で使用する備品の貸出・返却・在庫管理を効率化し、リアルタイムで備品の状態を把握できる環境を提供する。
1.2 システム構成の基本方針
Panasonic FZ-N1 (Android 6.0.1) を主たる操作端末(メインインターフェース)とし、現場での高速かつ確実な備品操作を実現する。
Web管理画面(Sho-Roomモジュール) は、マスタ管理や高度な検索、ラベル印刷、備品データの手動バックアップ等の管理的役割を担い、学祭委員全員が使用できるツール として、FZ-N1アプリを補完する位置づけとなる。
1.3 対象ユーザー
- 学祭委員全員: Webインターフェース(Sho-Room)を使用
- 現場スタッフ: FZ-N1端末を使用した備品操作
- 管理者: システム全体の管理、データ同期、年度更新等
2. アーキテクチャ構成
2.1 技術スタック
バックエンド
- Platform: Cloudflare Workers (Hono)
- Database: Cloudflare D1 (2 Database 構成)
- Equipment DB (
soshosai-equipment-db): 備品、場所、履歴、ログを管理 - System DB (
soshosai-system-db): 実行委員(スタッフ)情報を管理。認証時に参照
- Equipment DB (
- KV Store: Cloudflare KV (
soshosai-equipment-otp-kv)- OTP情報の一時保存(有効期限: 30分)
- ORM: Drizzle ORM(または同等のTypeScript対応ORM)による型安全なデータベース操作
- バリデーション: Zod / drizzle-orm/zod による型の正確なバリデーション
フロントエンド
- Handy (Main): Native Android (Java) - Panasonic FZ-N1専用アプリ
- Android 6.0.1 (API Level 23)
- minSdk = 23
- Web (Sub): React (TypeScript) / Sho-Roomモジュール(レスポンシブデザイン)
- デプロイ: Cloudflare Pages
- BFF (Backend for Frontend): Cloudflare Pages Functions
- テスト: Vitest
2.2 ドメイン構成
本システムは以下のドメイン構成で運用される:
- login-api:
loginapi.soshosai.com- 認証フロントエンド - auth-workers: 認証バックエンドサービス群(Service Binding経由)
discord-auth-worker: Discord OAuth2認証line-auth-worker: LINE OAuth2認証unified-auth-worker: JWT検証・BAN管理
- Sho-Room:
room.soshosai.com- 学祭委員向け管理画面(本システム) - 来場者アプリ:
app.soshosai.com- 来場者向けアプリ(別システム) - 共通API:
api.soshosai.com- バックエンドAPI(共通)
2.3 認証アーキテクチャ
認証は login-api と auth-workers の2層構成で実装する。
認証システムの詳細は認証システム仕様書を参照
概要:
| コンポーネント | 役割 |
|---|---|
login-api | 認証フロントエンド。OAuthリダイレクト、Cookie設定 |
discord-auth-worker | Discord OAuth2認証・ロール検証 |
line-auth-worker | LINE OAuth2認証 |
unified-auth-worker | JWT検証・BAN管理・レート制限 |
BFFパターン:
Sho-RoomではCloudflare Pages Functionsを使用したBFFパターンを採用。
CookieからJWTを取得し、Authorization: Bearer xxx ヘッダーに変換してAPIに転送する。
Browser → Pages (React) → Pages Functions (BFF) → API → unified-auth-worker
Cookie Header変換
2.4 ID戦略
- システム内部ID: すべてのユニークID(プライマリキー)は UUID v7 を使用
- 操作ログの記録者ID: Discord User ID を使用(
ItemHistory.actor_id)
2.5 データフロー
- Items(備品): D1がマスター(Single Source of Truth)。手動操作(Export/Import)でのみGoogle Spreadsheetと連携
- Staffs(名簿): 外部システムにより
soshosai-system-dbへデータが供給される前提。本システムからは 読み取り(認証照合・IDm更新)のみ を行う - Locations / Logs: D1内のみで管理
- KV (Key-Value Store):
- OTP情報の一時保存(キー:
otp:{student_id}, 有効期限: 30分)
- OTP情報の一時保存(キー:
2.6 システム構成図
3. データベース設計
本システムは2つのD1データベースを使用する。
詳細は各DB仕様書を参照
3.1 Equipment DB (soshosai-equipment-db)
備品管理専用のデータベース。
| テーブル | 用途 |
|---|---|
Locations | 場所マスター |
Organizations | 使用団体マスター |
ItemTypes | 備品種別マスター |
Items | 備品マスター |
ItemHistory | 操作ログ |
SyncLog | 同期ログ |
AuthorityChangeLog | 権限変更ログ |
詳細: 備品DB設計
3.2 System DB (soshosai-system-db)
システム共通のデータベース。本システムからは読み取りとNFC IDm更新のみを行う。
| テーブル | 用途 |
|---|---|
Staffs | スタッフマスター(discord_id, student_id, nfc_idm) |
詳細: システムDB設計
4. 非機能要件
4.1 パフォーマンス要件
- 備品一覧画面: 2000件以上のデータでもストレスなく高速表示すること
- その他画面: 一般的なWebアプリケーションとして妥当な表示速度を維持すること
- 想定同時接続数: 最大30人
4.2 セキュリティ要件
-
認証: login-api / soshosai-authorization-serviceが発行するJWTで厳密に検証
-
認可: soshosai-authorization-serviceで権限を厳密に検証
-
権限モデル: JWTの
permissionsフィールドに格納されたビットフラグで権限を管理する。Discordロールから自動計算され、ORで合成される。操作 必要な権限ビット 備品一覧・詳細閲覧 EQUIPMENT_VIEW(64)備品登録・編集 EQUIPMENT_EDIT(128)備品削除 EQUIPMENT_DELETE(256)備品連携(import/export) EQUIPMENT_INTEGRATION(512)備品リセット EQUIPMENT_RESET(1024)団体管理 EQUIPMENT_MANAGE_ORGANIZATION(2048)場所管理 EQUIPMENT_MANAGE_LOCATION(4096)備品種別管理 EQUIPMENT_MANAGE_TYPE(8192)備品一括操作 EQUIPMENT_BULK_OPERATION(16384)システム・特殊操作 SYSTEM_MANAGE(32)全操作 ALL_PERMISSIONS(全ビットOR)
4.3 可用性
- Cloudflareを使用し、基本的に常時稼働
4.4 データ管理
- バックアップ/リストア: Cloudflareの機能を利用
- データ保持期間: 制限なし
- データ移行: 対応不要
- 学祭準備期間の特別対応:
- 学祭前日〜学祭翌日の期間、大学環境のcronサーバーから定期的に全データを取得
- 専用API(
GET /system/export/all)を使用して全備品データをエクスポート - 認証方式: JWT認証ではなく、専用の認証トークン(API Key方式)を使用
- cronサーバーから5分ごとにデータを取得し、大学環境のローカルDBに保存
- 詳細な実装は別途定義
5. API仕様
5.1 共通仕様
5.1.1 認証
- 全APIエンドポイントはJWTによる認証が必須
Authorization: Bearer <JWT>ヘッダーでトークンを送信- JWTペイロードには操作者の Discord User ID を含める(操作ログ記録用)
5.1.2 レスポンス形式
成功時:
{
"status": 200,
"data": { ... }
}
エラー時:
{
"status": 400,
"message": "エラーメッセージ(error.messageの内容)"
}
5.1.3 HTTPステータスコード
enum HttpStatus {
// 成功系
OK = 200, // 成功
CREATED = 201, // 作成成功
ACCEPTED = 202, // 受理
NO_CONTENT = 204, // コンテンツなし(削除成功など)
// リダイレクト系
MOVED_PERMANENTLY = 301, // 恒久的に移動
FOUND = 302, // 発見
NOT_MODIFIED = 304, // 未更新
// クライアントエラー系
BAD_REQUEST = 400, // リクエスト不正
UNAUTHORIZED = 401, // 認証失敗
FORBIDDEN = 403, // 権限不足
NOT_FOUND = 404, // リソースが見つからない
METHOD_NOT_ALLOWED = 405, // 許可されていないメソッド
NOT_ACCEPTABLE = 406, // 受理できない
CONFLICT = 409, // 競合
GONE = 410, // 消滅
PAYLOAD_TOO_LARGE = 413, // ペイロードが大きすぎる
UNSUPPORTED_MEDIA_TYPE = 415, // サポートされていないメディアタイプ
UNPROCESSABLE_ENTITY = 422, // 処理できないエンティティ(バリデーションエラー)
TOO_MANY_REQUESTS = 429, // リクエスト過多
// サーバーエラー系
INTERNAL_SERVER_ERROR = 500, // サーバーエラー
NOT_IMPLEMENTED = 501, // 未実装
BAD_GATEWAY = 502, // 不正なゲートウェイ
SERVICE_UNAVAILABLE = 503, // サービス利用不可
GATEWAY_TIMEOUT = 504, // ゲートウェイタイムアウト
}
主な使用例:
200 OK: 取得・更新成功201 CREATED: 作成成功204 NO_CONTENT: 削除成功400 BAD_REQUEST: リクエスト形式エラー401 UNAUTHORIZED: 認証失敗、JWTなし/無効403 FORBIDDEN: 権限不足404 NOT_FOUND: リソースが見つからない409 CONFLICT: データ競合(削除制約違反など)422 UNPROCESSABLE_ENTITY: バリデーションエラー500 INTERNAL_SERVER_ERROR: サーバー内部エラー
5.1.4 ページネーション
- Web UI:
start(開始位置)、end(終了位置)パラメータで範囲指定 - FZ-N1: 50件ごと、
start、endパラメータで範囲指定
5.2 認証API(Auth)
POST /auth/nfc
- 概要: FZ-N1用。NFCタッチによるログイン
- 対象: FZ-N1アプリ
- リクエストボディ:
{
"student_id": "s1234567",
"idm": "0123456789ABCDEF"
}
- 処理ロジック:
- System DBの
Staffsテーブルをstudent_idで検索 - レコードなし →
403 Forbidden(Error:NOT_IN_MEMBER_LIST) Staffs.nfc_idmがNULL→401 Unauthorized(Error:NEED_REGISTRATION)Staffs.nfc_idmと送信されたidmが不一致 →403 Forbidden(Error:INVALID_IDM)- 一致 →
200 OK+ JWT発行(Discord IDを含む)
- System DBの
- レスポンス例:
{
"status": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
}
}
POST /auth/register/nfc
- 概要: FZ-N1用。OTP検証によるIDm紐付け登録
- 対象: FZ-N1アプリ
- リクエストボディ:
{
"student_id": "s1234567",
"idm": "0123456789ABCDEF",
"otp": "123456"
}
- 処理ロジック:
- KVから
student_idをキーとしてOTP情報を取得 - OTPが存在しない、または有効期限切れ →
401 Unauthorized(Error:OTP_EXPIRED) - OTPが不一致 →
403 Forbidden(Error:INVALID_OTP) - OTP一致 → System DBの
Staffsテーブルをstudent_idで検索 - レコードなし →
403 Forbidden - レコードあり →
nfc_idmカラムを更新 - KVからOTP情報を削除
200 OK+ JWT発行(Discord ID含む)
- KVから
- レスポンス: POST /auth/nfcと同様
KVデータ形式 (キー: otp:{student_id}):
{
"otp": "123456",
"discord_id": "123456789012345678",
"expires_at": 1706745600
}
POST /auth/request-otp
- 概要: FZ-N1用。初回登録時のOTP発行リクエスト
- 対象: FZ-N1アプリ
- リクエストボディ:
{
"student_id": "s1234567"
}
- 処理ロジック:
- System DBの
Staffsテーブルをstudent_idで検索 - レコードなし →
403 Forbidden(Error:NOT_IN_MEMBER_LIST) nfc_idmが既に登録済み →409 Conflict(Error:ALREADY_REGISTERED)- レコードあり&未登録 → 6桁のOTPを生成(ランダムな数字)
- Discord APIを使用して特定チャンネル(channelId:
1452516548391731272)にプライベートスレッドを作成 - スレッド内で該当ユーザーをメンション+OTP+有効期限を表示
- OTP情報をKVに保存(キー:
otp:{student_id}, 有効期限: 30分) 200 OKを返却
- System DBの
- レスポンス例:
{
"status": 200,
"data": {
"message": "OTPを発行しました。Discordを確認してください。",
"expires_in": 1800
}
}
Discord通知内容例:
<@123456789012345678>
初回登録用のOTPを発行しました。
OTP: 123456
有効期限: 2026-01-31 15:30:00 (30分間)
FZ-N1アプリでこのOTPを入力してください。
POST /auth/discord/callback
- 概要: Sho-Room (Web)用。Discord OAuthコールバック処理
- 対象: Webアプリ
- リクエストボディ: OAuth code等
- レスポンス: JWT発行
5.3 スタッフ情報API(Staffs)
GET /staffs/me
- 概要: 現在ログイン中のスタッフ情報を取得
- 権限: 全ユーザー(要JWT認証)
- レスポンス例:
{
"status": 200,
"data": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
}
PUT /staffs/me
- 概要: 自分のスタッフ情報を更新
- 権限: 全ユーザー(要JWT認証)
- 備考: 現時点では更新可能な項目なし(将来的な拡張用)
5.4 場所管理API(Locations)
GET /locations
- 概要: 全場所リストの取得
- 権限: 全ユーザー
- レスポンス例:
{
"status": 200,
"data": {
"locations": [
{
"id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"name": "M1",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:00:00"
},
{
"id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"name": "講義棟",
"created_at": "2024-01-15T10:01:00",
"updated_at": "2024-01-15T10:01:00"
}
]
}
}
POST /locations
- 概要: 新規場所の登録
- 権限:
EQUIPMENT_MANAGE_LOCATION - リクエストボディ:
{
"name": "体育館"
}
- 処理:
idにはUUID v7を自動生成 - バリデーション:
name: 必須、1-50文字、空白のみ不可
- レスポンス例:
{
"status": 201,
"data": {
"id": "018d3f7a-7e6g-9d4c-bf3h-5ga8e6d4h3c",
"name": "体育館",
"created_at": "2024-01-26T14:30:00",
"updated_at": "2024-01-26T14:30:00"
}
}
PUT /locations/:id
- 概要: 場所情報の更新
- 権限:
EQUIPMENT_MANAGE_LOCATION - パスパラメータ:
id(UUID) - リクエストボディ:
{
"name": "新体育館"
}
- バリデーション: POST /locationsと同様
- レスポンス: 更新後の場所情報
DELETE /locations/:id
- 概要: 場所の削除
- 権限:
EQUIPMENT_MANAGE_LOCATION - パスパラメータ:
id(UUID) - 制約:
Itemsテーブルで参照されている場合(original_location_id、current_location_id)、削除を拒否し409 Conflictを返す - レスポンス:
{
"status": 204
}
5.5 団体管理API(Organizations)
GET /organizations
- 概要: 全団体リストの取得
- 権限: 全ユーザー
- レスポンス例:
{
"status": 200,
"data": {
"organizations": [
{
"id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"name": "展示ブース A",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:00:00"
}
]
}
}
POST /organizations
- 概要: 新規団体の登録
- 権限:
EQUIPMENT_MANAGE_ORGANIZATION - リクエストボディ:
{
"name": "展示ブース B"
}
- バリデーション:
name: 必須、1-100文字、空白のみ不可
PUT /organizations/:id
- 概要: 団体情報の更新
- 権限:
EQUIPMENT_MANAGE_ORGANIZATION
DELETE /organizations/:id
- 概要: 団体の削除
- 権限:
EQUIPMENT_MANAGE_ORGANIZATION - 制約:
Itemsテーブルで参照されている場合、削除を拒否し409 Conflictを返す
5.6 備品種別管理API(ItemTypes)
GET /item-types
- 概要: 全備品種別リストの取得
- 権限: 全ユーザー
- レスポンス例:
{
"status": 200,
"data": {
"item_types": [
{
"id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"name": "長机",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:00:00"
},
{
"id": "018d3f7a-ah9j-cg7f-ei6k-8jd1h9g7k6f",
"name": "椅子",
"created_at": "2024-01-15T10:01:00",
"updated_at": "2024-01-15T10:01:00"
}
]
}
}
POST /item-types
- 概要: 新規備品種別の登録
- 権限:
EQUIPMENT_MANAGE_TYPE - リクエストボディ:
{
"name": "プロジェクター"
}
- バリデーション:
name: 必須、1-50文字、空白のみ不可
PUT /item-types/:id
- 概要: 備品種別情報の更新
- 権限:
EQUIPMENT_MANAGE_TYPE
DELETE /item-types/:id
- 概要: 備品種別の削除
- 権限:
EQUIPMENT_MANAGE_TYPE - 制約:
Itemsテーブルで参照されている場合、削除を拒否し409 Conflictを返す
5.7 備品操作・検索API(Items)
GET /items
- 概要: 多機能検索とページネーション
- 権限: 全ユーザー
- クエリパラメータ:
start(integer, optional): 開始位置(デフォルト: 0)end(integer, optional): 終了位置(デフォルト: 50)q(string, optional): フリーワード検索(display_id、item_type_name、organization_nameへの部分一致)status[](string[], optional): ステータスフィルタ(複数指定可: STOCK, LENT, RETURNED)condition[](string[], optional): コンディションフィルタ(複数指定可)current_location_id[](string[], optional): 現在地フィルタ(複数指定可)item_type_id[](string[], optional): 種別フィルタ(複数指定可)organization_id[](string[], optional): 団体フィルタ(複数指定可)sort_by(string, optional): ソート項目(display_id/created_at/updated_at、デフォルト: display_id)sort_order(string, optional): ソート順(asc/desc、デフォルト: asc)
- レスポンス例:
{
"status": 200,
"data": {
"items": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"original_location_name": "M1",
"current_location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"current_location_name": "講義棟",
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"organization_name": "展示ブース A",
"note": "脚部に傷あり",
"is_active": 1,
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-26T09:00:00"
}
],
"meta": {
"total_count": 1523,
"start": 0,
"end": 50
}
}
}
GET /items/:id
- 概要: 備品詳細情報の取得
- 権限: 全ユーザー
- パスパラメータ:
id(UUID) - レスポンス例:
{
"status": 200,
"data": {
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"original_location_name": "M1",
"current_location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"current_location_name": "講義棟",
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"organization_name": "展示ブース A",
"note": "脚部に傷あり",
"is_active": 1,
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-26T09:00:00"
}
}
POST /items
- 概要: 新規備品の登録
- 権限:
EQUIPMENT_EDIT - リクエストボディ:
{
"display_id": "DESK-002",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"note": "新品"
}
- バリデーション:
display_id: 必須、1-50文字、重複不可、空白のみ不可item_type_id: 必須、存在するItemTypeのIDであることoriginal_location_id: 必須、存在するLocationのIDであることnote: 任意、最大500文字
- 処理:
idにはUUID v7を自動生成、statusはSTOCK、conditionはNORMAL、current_location_idはoriginal_location_idと同じ値で初期化 - レスポンス: 作成された備品情報(status: 201)
PUT /items/:id
- 概要: 備品情報の更新
- 権限:
EQUIPMENT_EDIT - パスパラメータ:
id(UUID) - リクエストボディ:
{
"display_id": "DESK-002-NEW",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"status": "STOCK",
"condition": "NEEDS_REPAIR",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"current_location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"organization_id": null,
"note": "脚部修理必要"
}
- バリデーション: POST /itemsと同様(
display_id変更時は重複チェック) - レスポンス: 更新後の備品情報
DELETE /items/:id
- 概要: 備品の削除(論理削除)
- 権限:
EQUIPMENT_DELETE - パスパラメータ:
id(UUID) - 処理:
is_activeを0に設定(論理削除) - レスポンス:
{
"status": 204
}
POST /items/organization-action/preview
- 概要: 出展許可証QRスキャン時の事前確認(一括操作プレビュー)
- 権限: 全スタッフ
- リクエストボディ:
{
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d"
}
- レスポンス例:
{
"status": 200,
"data": {
"organization": {
"id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"name": "展示ブース A"
},
"summary": [
{
"item_type_name": "長机",
"count": 4
},
{
"item_type_name": "椅子",
"count": 10
}
],
"items": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL"
},
{
"id": "018d3f7a-cj1l-ei9h-gk8m-alf3j1i9m8h",
"display_id": "CHAIR-001",
"item_type_name": "椅子",
"status": "LENT",
"condition": "BROKEN"
}
]
}
}
POST /items/organization-action
- 概要: 団体一括操作の実行(一括貸出/一括返却)
- 権限: 全スタッフ
- リクエストボディ:
{
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"action": "LEND"
}
- 処理ロジック:
- 指定された
organization_idに割り当てられている全備品のステータスを一括更新 actionがLENDの場合:statusをLENTに変更actionがRETURNの場合:statusをRETURNEDに変更ItemHistoryにログを追加(actor_idにはJWT内のDiscord IDを使用)
- 指定された
- レスポンス例:
{
"status": 200,
"data": {
"updated_count": 14,
"organization_name": "展示ブース A"
}
}
POST /items/sync
- 概要: FZ-N1からのバッチ更新(個別スキャン結果の送信)
- 権限: 全スタッフ
- リクエストボディ:
{
"updates": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"status": "RETURNED",
"current_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"condition": "NORMAL"
},
{
"id": "018d3f7a-cj1l-ei9h-gk8m-alf3j1i9m8h",
"status": "STOCK",
"current_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"condition": "NEEDS_REPAIR"
}
]
}
- 処理: 各備品を更新し、
ItemHistoryにログを記録 - レスポンス例:
{
"status": 200,
"data": {
"success_count": 2,
"failed_count": 0
}
}
GET /items/:id/history
- 概要: 特定備品の操作履歴取得
- 権限: 全ユーザー
- パスパラメータ:
id(UUID) - クエリパラメータ:
start(integer, optional): 開始位置end(integer, optional): 終了位置
- レスポンス例:
{
"status": 200,
"data": {
"history": [
{
"id": 1523,
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"actor_id": "123456789012345678",
"condition_type": "NORMAL",
"action_type": "RETURN",
"created_at": "2024-01-26T18:00:00"
},
{
"id": 1450,
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"actor_id": "123456789012345678",
"condition_type": "NORMAL",
"action_type": "LEND",
"created_at": "2024-01-26T09:00:00"
}
],
"meta": {
"total_count": 15,
"start": 0,
"end": 50
}
}
}
5.8 ダッシュボードAPI(Dashboard)
GET /dashboard/summary
- 概要: ダッシュボード用の集計データ取得
- 権限: 全ユーザー
- レスポンス例:
{
"status": 200,
"data": {
"metrics": {
"total_items": 1523,
"lent_items": 432,
"stock_items": 1089,
"needs_attention": 2
},
"recent_logs": [
{
"id": 1523,
"item_display_id": "DESK-001",
"item_type_name": "長机",
"actor_id": "123456789012345678",
"action_type": "RETURN",
"created_at": "2024-01-26T18:00:00"
}
]
}
}
GET /dashboard/location-stats
- 概要: 場所ごとの備品状況を集計
- 権限: 全ユーザー
- レスポンス例:
{
"status": 200,
"data": {
"storage_view": [
{
"location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"location_name": "M1",
"original_count": 500,
"current_count": 320
}
],
"venue_view": [
{
"location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"location_name": "講義棟",
"current_count": 180
}
]
}
}
5.9 システム管理API(System)
POST /system/sync/export
- 概要: D1 Items → Google Spreadsheet(バックアップ)
- 権限:
EQUIPMENT_INTEGRATION - レスポンス例:
{
"status": 200,
"data": {
"exported_count": 1523,
"timestamp": 1706745600
}
}
- 処理:
SyncLogテーブルに実行結果を記録
POST /system/sync/import
- 概要: Google Spreadsheet → D1 Items(一括取込/上書き)
- 権限:
EQUIPMENT_INTEGRATION - レスポンス例:
{
"status": 200,
"data": {
"imported_count": 1523,
"timestamp": 1706745600
}
}
- 処理:
SyncLogテーブルに実行結果を記録
POST /system/reset
- 概要: 年度更新用全リセット
- 権限:
EQUIPMENT_RESET - 処理ロジック:
- 全Itemsの
statusをSTOCKにリセット current_location_idをoriginal_location_idに戻すorganization_idをクリアItemHistoryに”RESET”ログを記録- 古いログをアーカイブ(削除)
- 全Itemsの
- レスポンス例:
{
"status": 200,
"data": {
"reset_count": 1523,
"timestamp": 1706745600
}
}
GET /system/export/all
- 概要: 学祭準備期間用全データエクスポート(大学環境のcronサーバー用)
- 権限: 専用API Key認証(JWT認証不使用)
- 認証方式:
- ヘッダー:
X-API-Key: <API_KEY> - 環境変数
CRON_API_KEYと照合
- ヘッダー:
- レスポンス例:
{
"status": 200,
"data": {
"items": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL",
"original_location_name": "M1",
"current_location_name": "講義棟",
"organization_name": "展示ブース A",
"note": "脚部に傷あり",
"updated_at": "2026-01-26T09:00:00"
}
],
"total_count": 1523,
"exported_at": "2026-01-31T15:00:00"
}
}
- 備考:
- 5分ごとにcronから呼び出される想定
- 全備品データを一括で返却(ページネーションなし)
- 大学環境のローカルDBに保存される
5.10 特殊操作API(Special Operations)
SYSTEM_MANAGE権限を持つユーザーのみが実行できる特殊な操作機能。
POST /special/idm-reregister
- 概要: 学生証IDmの再登録(学生証再発行対応)
- 権限:
SYSTEM_MANAGE - リクエストボディ:
{
"student_id": "s1234567",
"new_idm": "0123456789ABCDEF"
}
- 処理ロジック:
- System DBの
Staffsテーブルをstudent_idで検索 - レコードなし →
403 Forbidden(Error:NOT_IN_MEMBER_LIST) - レコードあり →
nfc_idmをnew_idmで更新 - 操作ログを記録
- System DBの
- レスポンス例:
{
"status": 200,
"data": {
"student_id": "s1234567",
"name": "会津太郎",
"old_idm": "FEDCBA9876543210",
"new_idm": "0123456789ABCDEF",
"updated_at": "2026-01-31T15:00:00"
}
}
POST /special/force-status-change
- 概要: 備品状態の強制変更(緊急時の異常状態修正)
- 権限:
SYSTEM_MANAGE - リクエストボディ:
{
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"status": "STOCK",
"condition": "NORMAL",
"organization_id": null,
"reason": "誤操作により状態が異常になったため強制修正"
}
- 処理ロジック:
- 指定された備品の状態を強制的に変更
ItemHistoryに特殊操作ログを記録(理由を含む)
- レスポンス例:
{
"status": 200,
"data": {
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"updated_fields": ["status", "condition", "organization_id"]
}
}
GET /special/staff-list
- 概要: 学祭委員一覧の取得
- 権限:
SYSTEM_MANAGE - クエリパラメータ:
start(integer, optional): 開始位置(デフォルト: 0)end(integer, optional): 終了位置(デフォルト: 50)search(string, optional): 学籍番号・氏名で検索
- レスポンス例:
{
"status": 200,
"data": {
"staffs": [
{
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
],
"total": 150,
"start": 0,
"end": 50
}
}
POST /special/staff-by-nfc
- 概要: NFCスキャンによる学祭委員情報取得
- 権限:
SYSTEM_MANAGE - リクエストボディ:
{
"student_id": "s1234567",
"idm": "0123456789ABCDEF"
}
- 処理ロジック:
- System DBの
Staffsテーブルをstudent_idまたはnfc_idmで検索 - レコードなし →
403 Forbidden - レコードあり → スタッフ情報を返却
- System DBの
- レスポンス例:
{
"status": 200,
"data": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
}
POST /special/change-authority
⚠️ 廃止予定: 権限はDiscordロールから自動計算されるため、このエンドポイントで変更しても次回ログイン時に上書きされる。権限変更はDiscordサーバーのロール管理で行うこと。
- 概要: 学祭委員の権限変更(昇格/降格)
- 権限:
SYSTEM_MANAGE - リクエストボディ:
{
"discord_id": "123456789012345678",
"new_permissions": 128,
"reason": "学祭期間中の責任者として一時的に昇格"
}
- 処理ロジック:
- System DBの
Staffsテーブルをdiscord_idで検索 - レコードなし →
404 Not Found - 操作ログを記録(変更前・変更後の権限ビット、理由を含む)
- System DBの
- レスポンス例:
{
"status": 200,
"data": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"old_permissions": 5,
"new_permissions": 128,
"updated_at": "2026-01-31T15:00:00"
}
}
6. エラー処理とログ
6.1 バックエンド(Hono)
- エラーハンドリング: try-catchでキャッチした
error.messageをそのままログに記録 - ログ送信: Service Bindingを使用した外部エラーログシステムに送信
- エラーレスポンス: HTTPステータスコードとエラーメッセージを返却
// エラーハンドリング例
try {
// 処理
} catch (error) {
// Service Bindingでログ送信
await env.ERROR_LOG.send({
message: error.message,
stack: error.stack,
timestamp: Date.now(),
});
// クライアントにエラーレスポンス
return c.json(
{
status: 500,
message: error.message,
},
500,
);
}
6.2 フロントエンド(Web App)
- エラー収集: エラーを内部で収集
- ログ送信: Sho-Roomのエラーログ送信機能と連携
- エラー表示: ユーザーにわかりやすいエラーメッセージを表示
6.3 FZ-N1アプリ
- エラー表示: ダイアログまたはトースト通知でエラー表示
- ログ記録: ローカルにログを保存し、オンライン時に送信
- 音声フィードバック: エラー時はBeep音で通知
7. ハンディアプリ仕様(FZ-N1 / Android 6.0.1)
7.1 開発環境
- Android Version: 6.0.1 (API Level 23)
- minSdk: 23
- 言語: Java 8
- 通信: HttpURLConnection または OkHttp 3.x(TLS 1.2対応必須)
- ビルドコマンド:
./gradlew clean assembleDebug - Toughpad SDK:
- Panasonic提供のToughpad SDK (.jar形式)
- 物理ボタン制御用ライブラリ
- A1-A3ボタン、サイドボタンのイベントハンドリング
7.1.1 物理ボタン仕様
FZ-N1には以下の物理ボタンが搭載されています:
画面下部の3つのボタン:
- A1ボタン (左): キャンセル・拒否操作
- A2ボタン (中央): 補助操作(画面により機能が変わる)
- A3ボタン (右): 確認・承認操作
サイドボタン:
- 左サイドボタン: 補助操作(現時点では未使用)
- 右サイドボタン: 補助操作(現時点では未使用)
動的な割り当て原則:
- 物理ボタンは 必要な画面でのみ機能を割り当て、不要な画面では何も動作しない
- 各画面で使用するボタンのみリスナーを登録する
- 使用しないボタンはイベントを無視する
画面ごとの割り当て例:
| 画面 | A1ボタン | A2ボタン | A3ボタン |
|---|---|---|---|
| 一括操作確認 | キャンセル | 概要/詳細切替 | 実行 |
| ログイン | - | - | - |
| OTP入力 | - | - | - |
| メインメニュー | - | - | - |
| 個別スキャン確認 | キャンセル | - | 保存 |
Toughpad SDK使用例:
// Toughpad SDKのインポート
import jp.co.panasonic.toughpad.android.api.appbtn.AppButtonManager;
import jp.co.panasonic.toughpad.android.api.appbtn.AppButtonManager.AppButtonListener;
// ボタンイベントリスナーの設定
AppButtonManager manager = new AppButtonManager(this);
// 一括操作確認画面の場合
manager.setAppButtonListener(new AppButtonListener() {
@Override
public void onAppButtonEvent(int buttonId, int event) {
if (event == AppButtonManager.APP_BUTTON_EVENT_DOWN) {
switch (buttonId) {
case AppButtonManager.APP_BUTTON_A1:
// A1ボタン(キャンセル)の処理
onCancelButtonPressed();
break;
case AppButtonManager.APP_BUTTON_A2:
// A2ボタン(概要/詳細切替)の処理
toggleDetailView();
break;
case AppButtonManager.APP_BUTTON_A3:
// A3ボタン(確認)の処理
onConfirmButtonPressed();
break;
}
}
}
});
// 画面を離れる時はリスナーを解除
@Override
protected void onDestroy() {
super.onDestroy();
if (manager != null) {
manager.release();
}
}
画面表示イメージ(一括操作確認画面):
┌─────────────────────────┐
│ │
│ (メインコンテンツ) │
│ │
├─────────────────────────┤
│ │
│ [キャンセル] [切替] [実行] │ ← A1, A2, A3ボタンに対応
│ │
└─────────────────────────┘
- A1ボタンを押すと「キャンセル」
- A2ボタンを押すと「概要/詳細切替」
- A3ボタンを押すと「実行」
- 画面下部には物理ボタンに対応する表示を配置
7.2 認証フロー詳細
アプリ起動時はログイン画面を表示する。
A. NFCログイン(Primary Flow)
ステップ1: NFCリーダー待機
- アプリ起動後、NFCリーダーを有効化しFeliCaをポーリング
- 画面に「学生証をタッチしてください」と表示
ステップ2: FeliCa検知と読取
- System Code:
0x809E - PMm:
03 32 42 82 82 47 AA FF(会津大学学生証識別) - 読取ロジック:
IDm(8byte)を取得- Service Code
0x300BBlock 0を読み取る - 取得した16byteデータに対し、先頭4文字をスキップし、続く7文字を抽出(例:
1234567) - 先頭に
sを付与し、s1234567形式の学籍番号を生成- 例外処理: 上記以外のカード → エラー音(Beep) + 画面に「無効なカードです」と表示
ステップ3: API照合
-
POST /auth/nfcを送信 -
Body:
{ "student_id": "s1234567", "idm": "0123456789ABCDEF" } -
レスポンス処理:
- 成功(200):
1. **成功音を鳴らす**2. **確認画面を表示**(学籍番号を表示)3. 「OK」ボタンタップで **メインメニューへ遷移**- 許可なし(403): 警告音 + エラーダイアログ「権限がありません(名簿未登録)」
- 未登録(401 - NEED_REGISTRATION): 初回登録フロー(B)へ遷移
確認画面UI:
- タイトル: 「ログイン確認」
- 表示項目:
- 学籍番号:
s1234567
- 学籍番号:
- ボタン: 「OK」
B. OTPによる初回登録(Secondary Flow)
APIから「名簿にはあるがIDm未登録」と判定された場合、Discord経由でOTPを発行し、FZ-N1アプリで入力して本人確認を行う。
ステップ1: OTP発行リクエスト
POST /auth/request-otpを送信- Body:
{ "student_id": "s1234567" }
- Body:
- APIがDiscordの特定チャンネル(channelId:
1452516548391731272)にプライベートスレッドを作成 - スレッド内で該当ユーザーをメンション+6桁のOTP+有効期限を表示
- OTP情報がKVに保存される(有効期限: 30分)
ステップ2: OTP入力画面表示
- UI構成:
- タイトル: 「本人確認」
- メッセージ: 「Discordに送信されたOTPを入力してください」
- 独自実装のテンキー(6桁入力用):
- 0-9の数字ボタン
- バックスペースボタン(入力欄右端に表示)
- クリアボタン
- 入力表示エリア(6桁、入力した数字をそのまま表示)
- ボタン: 「確認」
- 注意事項: 「OTPの有効期限は30分です」
ステップ3: OTP検証
- ユーザーが6桁のOTPを入力
- 「確認」ボタンタップで
POST /auth/register/nfcを送信- Body:
{ "student_id": "s1234567", "idm": "0123456789ABCDEF", "otp": "123456" }
- Body:
- レスポンス処理:
- 成功(200): 成功音 + 「登録完了」トースト + メインメニューへ遷移
- OTP期限切れ(401 - OTP_EXPIRED): エラー音 + 「OTPの有効期限が切れています。最初からやり直してください」
- OTP不一致(403 - INVALID_OTP): エラー音 + 「OTPが正しくありません」
- 通信エラー: エラー音 + 「通信エラー: 再試行してください」
テンキーUI仕様:
┌─────────────────────────┐
│ワンタイムパスワード入力: 123456 ← │
├─────────────────────────┤
│ [ 1 ] [ 2 ] [ 3 ] │
│ [ 4 ] [ 5 ] [ 6 ] │
│ [ 7 ] [ 8 ] [ 9 ] │
│ [ C ] [ 0 ] [ 確認 ] │
└─────────────────────────┘
- 入力した数字をそのまま表示(マスクなし)
- ←: バックスペース表示(入力欄右端に表示、1桁削除)
- C: クリア(全削除)
- 確認: OTP検証実行(6桁入力完了後に有効化)
7.3 メニュー・ナビゲーション
ログイン後、以下の機能をタイル状(またはリスト)のメニューで選択可能にする。
- 個別スキャン(Single Scan)
- 一括貸出(Organization Lend)
- 一括返却(Organization Return)
- 設定(Settings): ログアウト、未送信データ確認
7.3.1 ログアウト機能
ログアウト方法:
- メニューから「設定」を選択し、「ログアウト」ボタンをタップ
- アプリ終了時に自動的にログアウト
ログアウト処理:
- ローカルに保存されたJWTを削除
- 未送信データがある場合、確認ダイアログを表示
- 「送信してログアウト」: 未送信データを送信後にログアウト
- 「データを破棄してログアウト」: 未送信データを削除してログアウト
- 「キャンセル」: ログアウトを中止
- ログイン画面へ遷移
7.4 機能詳細: 団体一括操作(Organization Action)
一括貸出フロー:
- QRスキャン: 出展許可証QR(
https://.../?organization_id=...形式のURL)を読み取る - プレビューAPIコール:
POST /items/organization-action/preview - プレビュー画面(UI詳細):
- ヘッダー: 団体名を表示
- 表示切替: 上部に [概要] [詳細] のセグメントボタン(またはタブ)
- [概要]タブ:
- デフォルト表示
長机: 4,椅子: 10のように種別と個数を大きな文字でリスト表示
- [詳細]タブ:
- 全対象備品のリスト
- 各行:
[DisplayID] [種別] [状態(日本語)] - 警告表示:
conditionがNORMAL以外(破損・紛失等)の場合、行の背景色を赤/黄色にし、警告アイコンを表示
- フッター(物理ボタン対応):
┌─────────────────────────┐
│ │
│ [キャンセル] [切替] [実行] │ ← A1, A2, A3の物理ボタンに対応
│ │
└─────────────────────────┘
- A1ボタン(左): キャンセル → 前の画面に戻る
- A2ボタン(中央): 概要/詳細切替 → 表示モードを切り替え
- A3ボタン(右): 実行 → 一括貸出を実行
- 実行: A3ボタン押下で API
POST /items/organization-action送信 → 完了画面- Body:
{ "organization_id": "...", "action": "LEND" }
- Body:
- 完了音: 成功音を鳴らし、メインメニューへ戻る
一括返却フロー:
- 一括貸出と同様の流れ
actionパラメータをRETURNに設定- フッター表示:
┌─────────────────────────┐
│ │
│ [キャンセル] [切替] [返却] │ ← A1, A2, A3の物理ボタンに対応
│ │
└─────────────────────────┘
- A2ボタンで概要/詳細を切り替え可能
7.5 機能詳細: 個別スキャン
動作フロー:
- バーコードをスキャン(備品のUUID v7が格納されたQRコード)
- API検索(
GET /items/:id) - 結果表示
表示内容:
Display ID(DESK-001等)を画面中央に大きく表示- 現在のステータス(在庫/貸出中/返却済)とコンディション(正常/要修理/破損等)を表示
- 備品種別、現在地、使用団体を表示
操作:
- その場で
Condition(正常/破損/…)やStatus(在庫/貸出…)を変更するボタン/プルダウンを配置 - 変更後、
POST /items/syncで一括送信
7.6 オフライン対応
基本動作:
- ネットワーク接続が切れた場合、操作結果をローカルに保存
- スキャン時に操作日時をタイムスタンプとして保存
- オンライン復帰時に
POST /items/syncで一括送信 - 未送信データがある場合、設定画面で確認・再送信可能
競合処理:
- 同期時に各備品の
updated_atをサーバーから取得 - ローカルの操作日時とサーバーの
updated_atを比較 - サーバーの
updated_atの方が新しい場合、その備品の更新をスキップ(競合回避) - スキップされた備品は同期結果画面で通知
7.7 音声フィードバック
- 成功音: 操作成功時(貸出/返却完了、ログイン成功)
- エラー音: エラー発生時(無効なカード、権限なし、通信エラー)
- 警告音: 注意が必要な場合(破損備品の検出など)
8. Webアプリ仕様(Sho-Roomモジュール)
8.1 開発環境
基本構成
- フレームワーク: React 18+ (TypeScript)
- スタイリング: Tailwind CSS
- ビルドツール: Vite
- デプロイ: Cloudflare Pages
- BFF: Cloudflare Pages Functions
- 対象デバイス: PCおよびモバイル端末(レスポンシブデザイン)
プロジェクト構造
sho-room/
├── functions/ # Pages Functions (BFF)
│ └── api/
│ └── [[path]].ts # APIプロキシ
├── src/
│ ├── components/ # Reactコンポーネント
│ ├── hooks/ # カスタムフック
│ ├── utils/ # ユーティリティ関数
│ ├── services/ # API通信ロジック
│ ├── types/ # TypeScript型定義
│ ├── App.tsx
│ └── main.tsx
├── tests/ # テストファイル
│ ├── unit/ # 単体テスト
│ └── integration/ # 統合テスト
├── public/
├── package.json
├── vite.config.ts
├── vitest.config.ts
└── tsconfig.json
BFF実装(Pages Functions)
functions/api/[[path]].ts:
// Cloudflare Pages Functions - BFFプロキシ
interface Env {
API_URL: string; // https://api.soshosai.com
}
export async function onRequest(context: {
request: Request;
env: Env;
}): Promise<Response> {
const { request, env } = context;
// Cookieからトークンを取得
const cookieHeader = request.headers.get('Cookie') || '';
const token = cookieHeader
.split('; ')
.find((row) => row.startsWith('soshosai_staff_jwt='))
?.split('=')[1];
if (!token) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// api.soshosai.com に転送(CookieをAuthorizationヘッダーに変換)
const url = new URL(request.url);
const apiPath = url.pathname.replace('/api', '');
const apiUrl = `${env.API_URL}${apiPath}${url.search}`;
const response = await fetch(apiUrl, {
method: request.method,
headers: {
Authorization: `Bearer${token}`,
'Content-Type': 'application/json',
},
body:
request.method !== 'GET' && request.method !== 'HEAD'
? await request.text()
: undefined,
});
return response;
}
認証フロー
- ログイン:
loginapi.soshosai.comでOAuth/Discord認証 - リダイレクト: login-apiが
soshosai_staff_jwtをCookieに設定してroom.soshosai.comにリダイレクト - Cookieの設定:
Set-Cookie: soshosai_staff_jwt=eyJhbGc...;
Domain=room.soshosai.com;
Path=/;
HttpOnly;
Secure;
SameSite=Lax;
Max-Age=604800
- API通信: React側は相対パスで呼び出し、Pages FunctionsがプロキシとしてCookieをHeaderに変換
React側の実装例:
// src/services/api.ts
export async function fetchItems() {
// 相対パスで呼ぶだけ(Cookieは自動送信される)
const response = await fetch('/api/items');
if (!response.ok) {
if (response.status === 401) {
// 未認証の場合はlogin-apiにリダイレクト
window.location.href =
'https://loginapi.soshosai.com/login?redirect=room';
return;
}
throw new Error('API request failed');
}
return response.json();
}
開発サーバー
# Pagesの開発サーバー(Functions含む)
npm run dev
# または
wrangler pages dev -- npm run dev
デプロイ
# Cloudflare Pagesにデプロイ
npm run build
wrangler pages deploy dist
8.2 テスト仕様
テストフレームワーク
- テストランナー: Vitest
- テストライブラリ: @testing-library/react
- モック: vi (Vitestビルトイン)
テスト戦略
単体テスト (Unit Tests):
- 対象: ユーティリティ関数、カスタムフック、純粋な関数
- 配置:
tests/unit/ - 命名規則:
.test.tsまたは.test.tsx - カバレッジ目標: 80%以上
統合テスト (Integration Tests):
- 対象: コンポーネント + API通信、複数コンポーネントの連携
- 配置:
tests/integration/ - モック: APIレスポンスをモック
ファイル分割規則
厳密なファイル分割:
// ❌ 悪い例: 1ファイルに複数の責務
// utils.ts
export function formatDate(date: Date) { ... }
export function validateEmail(email: string) { ... }
export function calculateTotal(items: Item[]) { ... }
// ✅ 良い例: 1ファイル1責務
// date.ts
export function formatDate(date: Date) { ... }
// validation.ts
export function validateEmail(email: string) { ... }
// calculation.ts
export function calculateTotal(items: Item[]) { ... }
テストファイルの対応:
src/utils/date.ts → tests/unit/utils/date.test.ts
src/services/api.ts → tests/unit/services/api.test.ts
src/components/Item.tsx → tests/integration/Item.test.tsx
vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.test.{ts,tsx}',
'vite.config.ts',
'vitest.config.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
テスト例
単体テスト例(ユーティリティ関数):
// tests/unit/utils/date.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from '@/utils/date';
describe('formatDate', () => {
it('should format date to YYYY-MM-DD', () => {
const date = new Date('2026-01-31T15:00:00');
expect(formatDate(date)).toBe('2026-01-31');
});
it('should handle invalid date', () => {
expect(() => formatDate(new Date('invalid'))).toThrow();
});
});
統合テスト例(コンポーネント + API):
// tests/integration/ItemList.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ItemList } from '@/components/ItemList';
import * as api from '@/services/api';
vi.mock('@/services/api');
describe('ItemList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should display items from API', async () => {
vi.spyOn(api, 'fetchItems').mockResolvedValue([
{ id: '1', display_id: 'DESK-001', item_type_name: '長机' },
]);
render(<ItemList />);
await waitFor(() => {
expect(screen.getByText('DESK-001')).toBeInTheDocument();
expect(screen.getByText('長机')).toBeInTheDocument();
});
});
it('should show error on API failure', async () => {
vi.spyOn(api, 'fetchItems').mockRejectedValue(new Error('API Error'));
render(<ItemList />);
await waitFor(() => {
expect(
screen.getByText(/エラーが発生しました/),
).toBeInTheDocument();
});
});
});
テスト実行コマンド
# すべてのテストを実行
npm run test
# ウォッチモード
npm run test:watch
# カバレッジ計測
npm run test:coverage
# 特定のファイルのみ
npm run test -- date.test.ts
8.3 ダッシュボード(Dashboard)
管理者がログイン直後に見る画面。
表示要素:
- ステータスカード: 「総備品数」「貸出中」「在庫」「要対応(破損/紛失)」を表示
- 在庫分布チャート: 場所ごとの在庫数を棒グラフで可視化
- 場所別ステータス確認:
GET /dashboard/location-statsの結果を表示- 元の場所視点(在庫チェック用)と移動先視点(貸出チェック用)のテーブルを表示
- 最近の操作:
ItemHistoryから最新5件を表示
8.4 備品管理(Inventory)
一覧表示:
- カラム:
Display ID,種別,ステータス,コンディション(日本語),現在地,使用団体 - サーバーサイドページネーションを実装(1ページ50件など。表示件数は変更可能: 10/20/50/100/200)
- モバイル表示時: カード形式レイアウト等で見やすく調整
検索パネル:
- フリーワード:
display_id, 備品名, 団体名(Debounce対応) - フィルタ: 種別、状態、場所、コンディション(複数選択可)
編集モーダル:
- 備品情報の編集(
display_idの変更、備品種別、使用団体、元の場所、現在地、コンディション変更) - ラベル印刷ボタン(TM-L90への送信)
カメラQR読取:
- ブラウザAPIを使用し、備品QRをスキャンして編集モーダルを開く
バリデーション:
display_id: 必須、1-50文字、重複不可、空白のみ不可note: 任意、最大500文字
8.5 場所管理(Locations)
一覧表示:
- 全場所をテーブル形式で表示
- 備品数(総数、貸出中、在庫)を表示
CRUD操作:
- 場所の新規作成、編集、論理削除(
is_active=0) - 削除前に備品割当チェック(備品がある場所は削除不可)
8.6 団体管理(Organizations)
一覧表示:
- 全団体をテーブル形式で表示
- 割り当てられている備品数を表示
CRUD操作:
- 団体の新規作成、編集、論理削除
- 削除前に備品割当チェック
8.7 特殊操作(Special Operations)
アクセス: SYSTEM_MANAGE権限を持つユーザーのみ
機能メニュー:
-
学生証IDm再登録
- 学籍番号入力フォーム
- NFCカードリーダー対応(PCに接続されている場合)
- または、手動でIDm入力
- 実行前に確認ダイアログ表示
- API:
POST /special/idm-reregister
-
備品状態の強制変更
- 備品検索(Display IDまたはQRスキャン)
- 現在の状態表示
- 変更後の状態選択(ステータス、コンディション、団体)
- 理由入力欄(必須、最大500文字)
- 実行前に確認ダイアログ表示
- API:
POST /special/force-status-change
-
権限確認(⚠️ 権限変更はDiscordロール管理で行うこと)
方法1: 一覧から選択
- 学祭委員一覧を表示(ページネーション対応)
- 検索機能: 学籍番号・氏名で検索
- 各行に現在の
permissionsビット値を表示 - API:
GET /special/staff-list
方法2: NFCスキャン
- 「学生証をスキャン」ボタン
- NFCカードリーダー(PCに接続)で学生証を読み取り
- 該当する学祭委員情報を表示
- API:
POST /special/staff-by-nfc
注意事項:
- 全ての特殊操作は操作ログに詳細記録される
- 実行前に必ず確認ダイアログを表示
- 理由の入力を必須とする
- 権限はDiscordロールから自動計算される。
POST /special/change-authorityで変更しても次回ログイン時にDiscordロールの値で上書きされる - 新規登録ボタン(
dept_admin、super_adminのみ表示)
編集・削除:
- モーダルで実施
- 削除時は備品が残っていないかバリデーションを行う(
Itemsテーブルで参照されている場合は削除不可)
項目:
- 場所名(
name)の編集に対応
8.8 備品種別管理(ItemTypes)
場所管理と同様の構成。
8.9 システム管理(System)
管理者専用機能(super_admin、dept_adminのみアクセス可能)。
データ同期パネル:
- 備品エクスポート: 「スプレッドシートへ書き出し(Items Export)」ボタン
- クリックで
POST /system/sync/exportを実行
- クリックで
- 備品インポート: 「スプレッドシートから取り込み(Items Import)」ボタン(※赤色の警告ボタン)
- クリックで確認ダイアログを表示後、
POST /system/sync/importを実行
- クリックで確認ダイアログを表示後、
メンテナンス:
- 年度更新: 「全データをリセット(Reset for New Year)」ボタン(※確認ダイアログ2回表示)
super_adminのみ実行可能- クリックで
POST /system/resetを実行
9. 外部連携仕様
9.1 Google Spreadsheet
運用ルール:
- FZ-N1アプリ上またはWeb管理画面からの手動操作(インポート/エクスポート)時のみアクセス
- D1がマスター(Single Source of Truth)
Sheets構成:
Items: 備品データ(D1のミラー)- カラム:
id,display_id,item_type_name,status,condition,original_location_name,current_location_name,organization_name,note,is_active,created_at,updated_at
- カラム:
9.2 プリンター(Epson TM-L90)
基本情報
機種: Epson TM-L90 Label Printer
接続方式: ネットワーク接続(LAN)
通信方式: e-POS Bridgeを介した印刷
- 直接HTTPリクエストを送るのではなく、e-POS Bridge(PCまたは専用端末上で動作するElectron製ブリッジサービス)に対して、SDKまたはXMLコマンドを送信する形式
- Webアプリから、e-POS BridgeのIPアドレスを指定して通信を行う
- 注意: FZ-N1からの直接印刷は仕様上不可(Webアプリからのみ印刷可能)
- USBポート: TM-L90にはUSB接続端子が存在しないため、USBモードは使用不可
e-POS Bridge仕様
アプリケーション: Electron製プロキシサーバー
デフォルトポート: 15888 または 8008
CORS: すべてのオリジンを許可
主要エンドポイント:
| エンドポイント | メソッド | 説明 |
|---|---|---|
/health | GET | ヘルスチェック・設定確認 |
/set-printer | POST | プリンター設定の更新 |
/print/json | POST | JSON形式での印刷(推奨) |
/epos/service.cgi | POST | ePOS-Print XML形式での印刷 |
プリンター設定
初回設定API: POST /set-printer
{
"ipAddress": "192.168.1.100",
"useSsl": false,
"paperWidthMm": 80,
"connectionType": "network"
}
パラメータ:
ipAddress: プリンターのIPアドレス(必須)useSsl: SSL接続の使用(デフォルト:false)paperWidthMm: 用紙幅(デフォルト:80)connectionType: 接続タイプ(network固定、TM-L90にはUSBポートなし)
印刷データ形式
推奨: JSON形式(/print/json)
Instruction Mode(順次印刷)を使用:
{
"instructions": [
{
"type": "qr",
"content": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"size": 6,
"level": 2,
"align": "center"
},
{ "type": "feed", "lines": 1 },
{
"type": "text",
"content": "長机",
"align": "center",
"fontSize": 48,
"bold": true
},
{ "type": "feed", "lines": 1 },
{
"type": "text",
"content": "M1 → 講義棟",
"align": "center",
"fontSize": 24
},
{ "type": "feed", "lines": 1 },
{
"type": "text",
"content": "展示ブース A",
"align": "center",
"fontSize": 20
},
{ "type": "feed", "lines": 3 },
{ "type": "cut", "cutType": "full" }
]
}
JSON命令タイプ:
qr: QRコード生成content: データ(UUIDなど)size: 1-16(モジュールサイズ)level: 0-3(誤り訂正レベル: L/M/Q/H)align:left/center/right
text: テキスト印刷(ビットマップレンダリング)content: 印刷するテキストfontSize: フォントサイズ(ピクセル)bold: 太字(true/false)align: 配置
feed: 改行lines: 改行数
cut: 用紙カットcutType:full/partial
印刷内容仕様
印刷する情報:
- QRコード: 備品のUUID(
Items.id)を格納- サイズ: 6(モジュール)
- 誤り訂正: レベルM
- 配置: 中央
- 備品種別: 大きな文字で表示
- フォントサイズ: 48px
- 太字: ON
- 配置: 中央
- 元の場所と移動先:
{元の場所} → {移動先}形式で表示- フォントサイズ: 24px
- 配置: 中央
- 使用団体: 団体名を表示
- フォントサイズ: 20px
- 配置: 中央
印刷トリガー
Webアプリの実装:
// src/services/printer.ts
async function printLabel(item: Item) {
// e-POS Bridge自動検出
const port = await detectBridge([15888, 8008]);
const printData = {
instructions: [
{
type: 'qr',
content: item.id,
size: 6,
level: 2,
align: 'center',
},
{ type: 'feed', lines: 1 },
{
type: 'text',
content: item.item_type_name,
align: 'center',
fontSize: 48,
bold: true,
},
{ type: 'feed', lines: 1 },
{
type: 'text',
content: `${item.original_location_name} →${item.current_location_name}`,
align: 'center',
fontSize: 24,
},
{ type: 'feed', lines: 1 },
{
type: 'text',
content: item.organization_name || '',
align: 'center',
fontSize: 20,
},
{ type: 'feed', lines: 3 },
{ type: 'cut', cutType: 'full' },
],
};
const response = await fetch(`http://localhost:${port}/print/json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(printData),
});
return response.json();
}
// e-POS Bridge自動検出
async function detectBridge(ports: number[]): Promise<number> {
for (const port of ports) {
try {
const res = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(1000),
});
if (res.ok) return port;
} catch {}
}
throw new Error('e-POS Bridge not found');
}
UIでの使用:
- Webアプリの備品編集モーダルから「ラベル印刷」ボタンをクリック
- 印刷成功/失敗をトースト通知で表示
エラーハンドリング
よくあるエラーと対処:
| エラー | 原因 | 対処法 |
|---|---|---|
e-POS Bridge not found | アプリが起動していない | e-POS Bridgeアプリを起動 |
プリンターが設定されていません | /set-printer未実行 | プリンター設定APIを呼び出す |
接続タイムアウト | プリンター電源OFF/ネットワーク | プリンター電源・ネットワーク確認 |
開発・デバッグ
ヘルスチェック:
curl http://localhost:15888/health
レスポンス例:
{
"status": "running",
"printer": "configured",
"config": {
"ipAddress": "192.168.1.100",
"useSsl": false,
"paperWidthMm": 80
}
}
10. 運用・保守
10.1 保守体制
- 管理チームが保守管理を担当
10.2 サポート体制
- Sho-Room内のメール送信機能でエラーログ等を送信
- メールでのサポート対応
10.3 監視
- Cloudflare Analyticsでアクセス状況を監視
- エラーログの定期確認
11. テスト方針
11.1 Web App
- 開発環境:
wrangler dev --remoteで動作確認 - テスト環境: concurrencyを使用したHono/React同時実行環境で検証
- テスト項目:
- 各機能の動作確認
- バリデーションテスト
- 権限別の表示/操作テスト
- レスポンシブデザイン確認
- エラーハンドリング確認
11.2 FZ-N1アプリ
- ビルド:
./gradlew clean assembleDebugで毎回ビルド・検証 - テスト項目:
- バーコードスキャン動作確認
- NFCログイン動作確認
- OTP認証フロー確認(Discord通知、独自テンキー入力)
- オフラインモード動作確認
- API連携テスト
- エラーハンドリング確認
- 音声フィードバック確認
11.3 受け入れ基準
- 全機能が仕様通りに動作すること
- パフォーマンス要件を満たすこと(備品一覧2000件以上で高速表示)
- 権限管理が正しく機能すること
- エラーハンドリングが適切に行われること
- NFCログインが正常に機能すること
- オフライン対応が正常に機能すること
12. 権限マトリクス
| 機能 | staff | dept_admin | system_admin | super_admin |
|---|---|---|---|---|
| 備品一覧表示 | ○ | ○ | ○ | ○ |
| 備品詳細表示 | ○ | ○ | ○ | ○ |
| 備品登録 | × | ○ | × | ○ |
| 備品編集 | × | ○ | × | ○ |
| 備品削除 | × | × | × | ○ |
| 備品インポート | × | ○ | × | ○ |
| 備品エクスポート | × | ○ | × | ○ |
| 貸出処理(個別) | ○ | ○ | ○ | ○ |
| 返却処理(個別) | ○ | ○ | ○ | ○ |
| 一括貸出 | ○ | ○ | ○ | ○ |
| 一括返却 | ○ | ○ | ○ | ○ |
| 備品履歴表示 | ○ | ○ | ○ | ○ |
| 場所管理 | × | ○ | × | ○ |
| 団体管理 | × | ○ | × | ○ |
| 備品種別管理 | × | ○ | × | ○ |
| 在庫状況表示 | ○ | ○ | ○ | ○ |
| システムリセット | × | × | × | ○ |
権限不足時の動作:
- HTTPステータスコード:
403 FORBIDDEN - エラーメッセージ: “この操作を実行する権限がありません”
- UI: 該当機能のボタン/メニューを非表示
注意事項:
system_adminはSho-Room全体の管理者権限を持つが、備品管理に関する特別な権限は持たない- 備品関連の管理権限は
dept_adminとsuper_adminのみが持つ
13. 用語集
| 用語 | 説明 |
|---|---|
| 蒼翔祭(そうしょうさい) | 会津大学学園祭の正式名称 |
| 学祭 | 蒼翔祭の略称 |
| 学祭委員 | 学園祭実行委員(本システムの主要ユーザー) |
| 備品 | 貸出・返却の対象となる物品 |
| 備品種別 | 備品を分類するためのタイプ(長机、椅子など) |
| 場所 | 元の場所や移動先(教室、倉庫、露店など) |
| 団体 | 備品を使用する団体 |
| 出展許可証 | 団体に発行されるQRコード付き許可証(一括操作で使用) |
| 貸出 | 備品を誰かに渡すこと |
| 返却 | 貸出した備品を戻すこと |
| 在庫 | 利用可能な備品の数 |
| FZ-N1 | 現場で使用するPanasonic製Android端末 |
| Sho-Room | 学祭委員が使用する統合管理ツール(本システムはそのモジュール) |
| JWT | JSON Web Token(認証トークン) |
| OTP | One-Time Password(初回登録時の6桁ワンタイムパスワード、有効期限30分) |
| Service Binding | Cloudflare Workersの機能連携の仕組み |
| UUID v7 | 時系列でソート可能なユニークID |
| IDm | FeliCaカードの固有ID |
| e-POS Bridge | EPSON製プリンターとの通信ブリッジサービス |
14. 付録
14.1 QRコード仕様
- 形式: QRコード
- 内容: 備品UUID(Items.id)
- 生成: EPSON TM-L90で印刷
- サイズ: 2cm × 2cm程度
14.2 システム構成フロー図
14.3 データフロー図
変更履歴
| 日付 | バージョン | 変更内容 |
|---|---|---|
| 2026-02-03 | 2.8 | Notion対応: すべての箇条書き(- / 1.)の前に空行を追加。e-POS Bridge仕様を詳細化(JSON印刷形式、エンドポイント、実装例、エラーハンドリング)。TM-L90にUSBポートが存在しない旨を明記 |
| 2026-02-03 | 2.7 | BFFパターン(Backend for Frontend)を採用。Cloudflare Pages + Pages Functionsでセキュアな認証を実装。HttpOnly Cookieによる XSS対策。ドメイン構成の明記。Vitestによるテスト仕様を追加。厳密なファイル分割規則を策定 |
| 2026-02-03 | 2.6 | 学年・学生身分の情報を削除。OTP方式への変更に伴い不要になったAPI・UIを整理。StaffsテーブルとAPIレスポンスから該当フィールドを削除 |
| 2026-01-31 | 2.5 | 権限変更機能を追加。学祭委員一覧取得API、NFCスキャンによる検索API、権限変更API、AuthorityChangeLogテーブルを追加。Web特殊操作メニューに権限変更機能を実装 |
| 2026-01-31 | 2.4 | A2ボタンを概要/詳細切替に使用。物理ボタンの動的割り当て追加。最高管理者向け特殊操作機能追加(IDm再登録、強制状態変更)。AppButtonManager APIに更新 |
| 2026-01-31 | 2.3 | FZ-N1の物理ボタン(A1-A3)をToughpad SDKで制御する仕様を追加。スライド操作から物理ボタン操作に変更。画面UIを物理ボタン対応に更新 |
| 2026-01-31 | 2.2 | 初回登録をCampus Square方式からOTP方式に変更。Discord QRログイン削除。Zod/standard-validatorによるバリデーション追加。学祭準備期間用データエクスポートAPI追加。独自テンキーUI実装。FZ-N1からのプリンター操作を不可に変更(Webのみ) |
| 2026-01-31 | 2.1 | NFCログイン成功時の確認画面を追加。団体許可証→出展許可証に用語変更 |
| 2026-01-31 | 2.0 | 元仕様書を基に全面改訂。会津大学学園祭向け仕様を明記、NFCログイン詳細、一括操作、Mermaid図、SQLスキーマを追加 |
| 2026-01-31 | 1.1 | 非機能要件、権限管理、バリデーション、エラー処理、テスト方針を追加 |
| 2024-XX-XX | 1.0 | 初版作成 |