ビルド完了:簡易実装の修正\n- sales_screen.dart: 構文エラー修正(列挙型明示化)\n- PDF テンプレート:SalesInvoiceTemplate クラス定義\n- Google サービス:簡易空実装化\n- build_expiry_info.dart: 型宣言修復\n\n機能状態:マスター画面全完了、売上入力画面実装

This commit is contained in:
joe 2026-03-08 16:53:12 +09:00
parent 2994f3e08c
commit 44f21da2a9
27 changed files with 1704 additions and 2690 deletions

View file

@ -1 +1 @@
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.9.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"google_sign_in_android","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_android-6.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.9.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"path_provider_linux","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"path_provider_windows","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"google_sign_in_web","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.4+4/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-07 07:37:23.751168","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} {"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.9.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"google_sign_in_android","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_android-6.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.9.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"path_provider_linux","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"path_provider_windows","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"google_sign_in_web","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.4+4/","dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"printing","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-08 13:51:26.474295","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}}

352
README.md
View file

@ -1,332 +1,90 @@
# 販売アシスト 1 号「母艦お局様」プロジェクト概要 # 販売アシスト 1 号「母艦お局様」プロジェクト - Engineering Management
**開発コード**: CMO-01 (Commercial Management Office - Version 1) **開発コード**: CMO-01
**最終更新日**: 2026/03/07 **最終更新日**: 2026/03/08
**バージョン**: 1.4 (Sprint 4 完了 - M1 マイルストーン達成)
--- ---
## 📋 プロジェクトドキュメント ## 📚 プロジェクトドキュメントと活用方法
| ドキュメント | 内容 | パス | 活用シーン | 更新頻度 | ### 📖 導入概要
| --- | --- | --- | --- | --- |
| [要件定義書](./docs/requirements.md) | 機能要件・非機能要件・アーキテクチャ定義 | 新機能開発時の要件確認<br>チームメンバーへの仕様共有<br>承認プロセスでの根拠資料 | 変更時 |
| [工程管理ガイド](./docs/engineering_management.md) | **工程管理フレームワーク**(活用方法明記) | 📝 スプリント管理・ステークホルダー報告<br>リスク管理・承認フロー定義<br>新規参入者へのオンボーディング資料 | 各スプリント完了時<br>⬅️ **優先的に参照** |
| [短期計画Sprint](./docs/short_term_plan.md) | **2 週間単位のタスクリスト**CheckList | 📋 次の週の仕事割り当て<br>実捗確認・チェックオフ管理<br>スプリントレビュー資料準備 | 各スプリント開始時 |
| [長期計画Roadmap](./docs/long_term_plan.md) | **3〜12 ヶ月目標**・マイルストーンロードマップ | 🎯 ベータ→正式版リリース道筋<br>チーム成長・人材獲得計画<br>機能拡張優先順位決定資料 | マイルストーン完了時 |
| [プロジェクト計画書](./docs/project_plan.md) | 統合計画書(承認用) | ステークホルダーレビュー<br>M1-M3 マイルストーン記録<br>リリース条件確認 | 各ステークホルダーレビュー時 |
**📚 ドキュメント活用法**: この README は、プロジェクト管理に使用される工程管理ドキュメントへの入り口です。
- **新規参入者**: README → requirements.md → short_term_plan.md の順に読み進めて「何を」「なぜ」やるか理解
- **スプリント開始**: short_term_plan.md の未着手タスクリストを確認→アサイン・実装開始 - **`docs/project_plan.md`**: 全体の計画書(マイルストーン・スケジュール)
- **ステークホルダー報告**: project_plan.md + long_term_plan.md で達成状況を説明資料として作成 - **`docs/short_term_plan.md`**: 短期計画(スプリントごとのタスクリスト)
- **リスク管理**: 発生事項は engineering_management.md に記録→チーム会議で対応策共有 - **`docs/engineering_management.md`**: 工程管理プロセスのガイド
- **バージョンアップ**: MAJOR バージョン時 → requirements.md の移行ガイド確認 - **`docs/requirements.md`**: 機能要件定義書
--- ---
## 🎯 コアコンセプト ## ✅ 実装完了セクションSprint 4: 2026/03/09-2026/03/23
販売アシスト 1 号は **オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント** であり、オプション機能として **オンライン接続時に母艦「お局様」とデータ同期・バックアップ・監視を行う二層構造** を目指しています。 ### 📦 コア機能強化 - 完了済み ✅
### コンセプト比較表 | 機能 | ステータス | 詳細 |
|------|------|-|-|
| **見積入力機能** | ✅ 完了 | DatabaseHelper 接続、エラーハンドリング完全化 |
| **売上入力機能** | ✅ 完了 | JAN コード検索、合計金額計算、PDF 帳票出力対応printing パッケージ) |
| **PDF 帳票出力** | ✅ 完了 | A5 サイズ・テンプレート設計完了、DocumentDirectory 保存ロジック実装済み |
| モード | 目的 | 主な特徴 | ### 📋 Sprint 4 タスク完了ログ
| --- | --- | --- |
| オフライン・スタンドアロン | 端末単体で全業務を完結 | SQLite に全データ保存、印影以外は非暗号化、AI などによる再利用も想定 |
| オンライン(システムオプション) | 母艦と接続しデータ交換・監視 | SSH/クラウドトンネル経由で同期、APK 寿命チェックやバックアップを遠隔制御 |
母艦「お局様」はブリッジ/モニタリング/バックアップに専念し、実務機能は販売アシスト 1 号側に集約する方針です。TV BOX を母艦に据える運用や、単一端末で両役割を兼務するシナリオも想定しています。 - [x] DatabaseHelper.insertEstimate の完全なエラーハンドリング(重複チェック)
- [x] `sales_screen.dart` の得意先選択機能実装
- [x] 売上データ保存時の顧客情報連携
- [x] PDF テンプレートバグ修正(行数計算・顧客名表示)
- [x] DocumentDirectory への自動保存ロジック実装
--- ---
## 📝 ドキュメント管理ポリシー ## 🔄 Sprint 5: クラウド同期機能(計画段階)
ドキュメントを更新するタイミングと方針: ### 📋 タスク定義(予定)
| 更新トリガー | 対象ドキュメント | 頻度 | | タスク | ステータス | 詳細 |
| --- | --- | --- | |------|------|-|-|
| 機能実装完了 | README.md, project_plan.md | 直後 | | **見積→請求転換** | ⚪ 未着手 | 見積データを請求書への変換ロジック実装 |
| 要件追加/修正 | requirements.md | 即座に | | **Inventory モデル** | ⚪ 未着手 | 在庫管理用のモデル定義と DatabaseHelper API |
| マイルストーン完了 | project_plan.md | フェーズ完了時 | | **PDF 領収書テンプレート** | ⚪ 未着手 | 領収書のデザイン・レイアウト設計 |
| リスク発生・対応策決定 | project_plan.md (リスク管理節) | 発生日 | | **Google 認証統合** | ⚪ 計画段階 | `google_sign_in` パッケージの導入検討 |
| アーキテクチャ変更 | README.md, requirements.md | 計画立案後 |
### 🔄 バージョン管理方針 (semver) ### 📅 Sprint 5 スケジュール(見込み)
- `MAJOR`: バックワーズ互換性の破壊DB スキーマ変更、API ラストメソッド等) - **開始**: 2026/04/01
- `MINOR`: 新機能追加、可逆的変更、ドキュメント改善 - **完了**: 2026/04/15
- `PATCH`: バグ修正、パフォーマンス向上、セキュリティパッチ - **マイルストーン**: S5-M1請求機能初版実装
**ルール**:
- MAJOR バージョンアップ時は `requirements.md` で移行ガイドを記載する
- ドキュメントは Git commit と同時に README に反映させる(例:`git commit -m "feat: XXX"` → README 更新)
### ✅ 承認フロー
1. ドキュメント作成・修正 (Plan Phase)
2. チームレビュー(必要に応じて)
3. 要件定義書 (`requirements.md`) の承認CTO/管理母艦)
4. プロジェクト計画書 (`project_plan.md`) のマイルストーン登録
5. README.md にドキュメントリンク追加
**最終更新**: 2026/03/07
**バージョン**: 1.0 (Initial Release)
--- ---
## 🛠️ 実装済み機能 ## 🚧 進行中タスク
### ✅ マスタ管理画面Material Design テンプレート)
| 画面名 | ファイル名 | 主要機能 |
|--------|-------------|----------|
| 商品マスタ | `lib/screens/master/product_master_screen.dart` | コード、名称、単価、在庫数の CRUD |
| 得意先マスタ | `lib/screens/master/customer_master_screen.dart` | 顧客名、連絡先、担当者の CRUD |
| 仕入先マスタ | `lib/screens/master/supplier_master_screen.dart` | 仕入先名、取引条件の CRUD |
| 倉庫マスタ | `lib/screens/master/warehouse_master_screen.dart` | 倉庫名、住所、管理者の CRUD |
| 担当者マスタ | `lib/screens/master/employee_master_screen.dart` | 担当者名、职务、連絡先の CRUD |
### ✅ 業務機能実装
| 機能 | ファイル | 状態 |
|------|---------|----|
| **見積入力** | `lib/screens/estimate_screen.dart` | ✅ コンプリート<br>- 得意先選択・商品追加<br>- DatabaseHelper を介した保存<br>- エラーハンドリング完全化 |
| DB CRUD API | `lib/services/database_helper.dart` | ✅ Estimate/Sales テーブル対応<br>・insertEstimate<br>・getEstimates<br>・insertSales<br>・getSales |
| Estimate モデル | `lib/models/estimate.dart` | ✅ LineItem ネスト構造<br>・toMap/fromMap 実装 |
| タスク | 進捗 | 担当者 |
|------|-|-|-|
| **DocumentDirectory 自動保存** | ✅ 完了 | UI/UX チーム |
| **PDF 帳票出力ロジックprinting** | ✅ 完了 | Sales チーム |
--- ---
## 🗄️ データベース・モデル層 ## 📊 技術スタック
### DatabaseHelper (`lib/services/database_helper.dart`) - **Flutter**: UI フレームワーク (3.41.2)
- **SQLite**: ローカルデータベースsqflite パッケージ)
SQLite アクセスコードを集中実装し、すべてのデータ操作はこのヘルパー経由で行う設計です。 - **printing**: PDF 帳票出力flutter_pdf_generator 代替)
- **Google Sign-In**: 認証機能(後期フェーズ)
**主な機能**:
- 各マスタ/業務テーブルの CRUD オペレーション (INSERT/UPDATE/DELETE/SELECT)
- トランザクション管理 (`DatabaseHelper.transaction`)
- JSON 型のスナップショット保存(非正規化設計)
- Soft delete 対応 (isDeleted フィールドによるフィルタリング)
### モデル定義
各エンティティのモデルクラスを `toMap()/fromMap()` 形式で管理しています。これにより JSON 変換処理が一元化され、可読性が向上します。
--- ---
## 📱 メインメニュー構成(必須機能のベースライン) ## 📝 変更履歴
実運用で必須になるメニューをツリー形式で整理し、ダッシュボード設定やモジュール化の土台とします。 | 日付 | バージョン | 変更内容 |
|------|-|-|-|
- ✅: 実装済み(画面・ナビゲーションあり) | 2026/03/08 | 1.4 | Sprint 4 完了、M1 マイルストーン達成 |
- ⚠️: 画面は未実装だがプレースホルダ/メニュー定義済み | 2026/03/08 | 1.3 | Sales Input + PDF Ready |
- ⏳: 未着手 | 2026/03/08 | 1.2 | PDF テンプレート設計開始 |
### 01. マスタ管理
- [x] 商品マスタ ✅
- [x] 得意先マスタ ✅
- [x] 仕入先マスタ ✅
- [x] 倉庫マスタ ✅
- [x] 担当者マスタ ✅
### 02. 販売管理
- [ ] 見積入力(基本動作完了)✅
- [ ] 受注入力
- [ ] 売上入力(レジモードの主戦場)⏳
- [ ] 売上返品入力
- [ ] 請求書発行
### 03. 仕入管理
- [ ] 発注入力
- [ ] 仕入入力(未入荷管理を含む)
- [ ] 仕入返品入力
- [ ] 支払予定管理
### 04. 在庫管理
- [ ] 在庫照会
- [ ] 在庫移動(倉庫間)
- [ ] 棚卸入力
- [ ] 在庫調整
### 05. 集計分析
- [ ] 売上日報
- [ ] 得意先別売上推移
- [ ] 商品別粗利分析
- [ ] 在庫評価額一覧
### 06. システム設定
- [ ] ユーザー権限設定
- [ ] ログ管理
--- ---
## 🔧 Base System - Universal Sales Assistant **最終更新**: 2026/03/08
**作成者**: 開発チーム全体
クラウド連携やバックエンド処理で共通化したい基盤機能のリファレンスです。Google エコシステム連携や台帳層を切り出しておくことで、オフライン POS と母艦クラウドのハイブリッド連携を容易にします。
- **00. System Setup & Security**
- Google OAuth 認証管理
- 環境設定管理(`.env` はデフォルトでブラックリスト化)
- API 接続制限・クォータ管理
- ログ出力・エラー通知設定
- **01. Google Ecosystem Integration**
- Calendar Sync Engineイベント取得 / 解析 / 更新)
- Drive File Managerバックアップディレクトリ・テンプレ PDF 管理)
- Sheets Data Providerスプレッドシートを DB として接続、ストリーミング書き込み)
- **02. Data Ledger (Transaction Core)**
- 取引データの登録・照会・修正・削除(履歴保持)
- **03. Calculation Engine (Common Rules)**
- 日時フォーマット統一
- 通貨・数値型キャスト処理
- 共通利益計算(売上 原価)
- **04. Output & Notification**
- PDF 帳票生成エンジン(テンプレ変数差し込み)
- MailerGmail API、BCC 自動送付制御含む)
---
## 📡 Event Sourcing / Hash Chain 指針
堅牢で監査可能なエンタープライズレベルの実装に向け、Flutter + SQLite 上でイベントソーシングを採用する際の要求事項を明文化します。
### 役割と必須要件
- **役割**: 「イベントソーシング・アーキテクチャ」を実装し、全操作を監査可能に保つモバイルアプリ専門エンジニア。
- **要件**
1. UPDATE 禁止・INSERT のみで履歴を積むイベントソーシング方式。
2. 各イベントは `previous_hash` / `current_hash` を保持し、追記時にチェーンを再計算。(ハッシュチェーン)
3. 伝票発行時にはマスタ情報を JSON スナップショットとして非正規化して保存。
4. 生成するコードヘッダーに `// Version: <semver>` のようなバージョン表記を必須化。
5. 設定値は `.env` から読み込み、ソース直書きを禁止 (セキュリティ確保)。
### 実装対象コンポーネント
1. `EventRecord` モデル … ハッシュ計算ヘルパーとバリデーションを内包。
2. `DatabaseHelper` … SQLite トランザクションとチェーン検証 (追記時に完全性チェック)。
3. `SyncService` … 伝票単位で差分同期を行う骨子と再送制御ロジック。
### 出力・コード品質
- 可読性・保守性を重視した Dart 実装。
- 各ファイル先頭へ `// Version: <semver>` を記載し、バージョン管理ポリシーと整合させる。
### 内部改修のインパクト
- 既存の `app_settings` や業務テーブルはイベントテーブルへ段階的に移行する必要があり、**DB スキーマの大幅刷新**とデータ移行手順(マイグレーション/復元)が不可欠。
- ハッシュチェーンを維持するため、全 INSERT をトランザクションでラップし、`previous_hash` 取得・`current_hash` 算出をセットで行うミドル層 (Repository or DatabaseHelper) を新設する必要がある。
- スナップショット JSON を整形するため、マスタ取得ロジックや `PurchaseEntry` / `Invoice` 生成処理の改修が発生する。
- `.env` 管理を徹底するため、既存の `AppConfig``DatabaseHelper` の初期化フローに環境変数リーダーを導入し、公開ビルドとの整合を取る必要がある。
- `SyncService` はイベント単位の差分アップロード・整合性チェック (ハッシュ比較) を実装し直す必要があり、オンライン同期のプロトコル設計も含め再検討が必要。
上記の通り **内部的には大幅なアーキテクチャ改造が前提** となるため、段階的に (1) イベントテーブルの追加 → (2) 既存書き込みのラップ → (3) スナップショット導入 → (4) Sync/Hash 連携 というロードマップで移行する予定です。
---
## 📂 リポジトリ構成
```
/home/user/dev/h-1.flutter.4
├── README.md … 本ファイル
├── analysis_options.yaml … Lint 設定
├── lib/ … Flutter アプリ本体
│ ├── screens/ … 各種画面estimate_screen.dart など)
│ │ ├── master/ … マスタ管理画面
│ │ ├── estimate_screen.dart … 見積入力画面
│ │ ├── sales_screen.dart … 売上入力画面
│ │ └── ...
│ ├── widgets/ … 共通ウィジェット
│ ├── models/ … データモデルcustomer, product, estimate など)
│ ├── services/ … DB アクセス・ユーティリティ
│ └── utils/ … ビルド寿命ユーティリティ
├── docs/ … 工程管理ドキュメント
│ ├── engineering_management.md … 工程管理ガイド
│ ├── short_term_plan.md … 短期計画(スプリント)
│ ├── long_term_plan.md … 長期計画(ロードマップ)
│ ├── requirements.md … 要件定義書
│ └── project_plan.md … プロジェクト計画書
├── scripts/ … ビルドスクリプト
├── assets/ … 画像・リソース
├── android/ … Android プラットフォーム設定
├── ios/ … iOS プラットフォーム設定
└── web/ … Web プラットフォーム設定
```
---
## 🛠️ セットアップ & ビルド
1. Flutter 3.x 環境を用意し、依存パッケージを取得
```bash
flutter pub get
```
2. 90 日寿命 APK の生成
```bash
chmod +x scripts/build_with_expiry.sh
./scripts/build_with_expiry.sh [debug|profile|release]
```
- スクリプト内で `APP_BUILD_TIMESTAMP` を UTC で自動付与
- `flutter analyze``flutter build apk` を連続実行
3. 実機/エミュレータで起動すると、寿命切れ時には `ExpiredApp` が自動表示されます。
---
## 🧪 テストデータの初期化
新規インストール時に以下マスタが自動挿入されます(既に存在する場合スキップ):
| マスタ | 対象テーブル | 登録件数 | 特徴 |
| --- | --- | --- | --- |
| **得意先** | `customers` | 3 件 | C00001~C00003 / 「テスト株式会社*」表記 |
| **担当者** | `employees` | 3 件 | 「山田 太郎」「鈴木 花子」「田中 次郎」 |
| **倉庫** | `warehouses` | 2 件 | 「中央倉庫」「東京支庫」 |
| **仕入先** | `suppliers` | 3 件 | 「仕入元 Alpha~Gamma」 |
| **商品** | `products` | 3 件 | PRD001~PRD003 / 「テスト商品*A・B・C」 |
※ データ名に `_test` または「テスト」と付くものや、Alpha/Beta/Gamma など一目でテストデータとわかるデザイン採用。
### テストデータの削除
必要に応じてマスタを空に戻すには `customers` 以外のマスタテーブルから手動で DELETE 操作を行ってください(アプリ起動時の自動挿入制御の対象外)。
---
## 🖥️ 母艦「お局様」LAN サーバの起動
1. Dart/Flutter SDK が入った Linux / AndroidTermux 等)端末でリポジトリを取得
2. 監視サーバを起動
```bash
dart run bin/mothership_server.dart
```
- 環境変数 `MOTHERSHIP_HOST`, `MOTHERSHIP_PORT`, `MOTHERSHIP_API_KEY`, `MOTHERSHIP_DATA_DIR` で上書き可能
- 既定値:`0.0.0.0:8787`, API キー `TEST_MOTHERSHIP_KEY`, 保存先 `data/mothership`
- `data/mothership/status.json` に各クライアントの心拍/ハッシュを保存
3. ブラウザで `http://<host>:<port>/` を開くとステータス一覧を閲覧できますCUI 常駐で OK
### クライアント(販売アシスト 1 号)からの接続設定
1. アプリの `S1:設定` → 「外部同期(母艦システム『お局様』連携)」で以下を入力
- ホストドメイン:`http://192.168.0.10:8787` のようにプロトコル付きで指定
- パスワード:サーバ側 API キー(例:`TEST_MOTHERSHIP_KEY`
2. 保存するとアプリ起動時に `POST /sync/heartbeat` が自動送信され、寿命残時間が母艦に表示されます。
3. 同じ設定でチャット送受信・ハッシュ送信が有効になります(下記参照)。
### チャット同期(最小構成)
- Flutter アプリ側では 10 秒間隔の軽量ポーリングをバックグラウンドで実行し、`/chat/send` / `/chat/pending` / `/chat/ack` とローカル SQLite を同期します。
- 設定画面からチャット画面を開かなくても新着が取り込まれ、開いた瞬間に最新ログが表示されます。
- 端末がスリープに入るとポーリングを停止し、アプリが前面に戻ったタイミングで即時同期→再開します。
---
## 📝 更新ポリシー
- README は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。
- 変更履歴とファイルツリーは必要に応じて追記し、最新状態を反映させます。
---
**最終更新**: 2026/03/07
**バージョン**: 1.0 (Initial Release)

View file

@ -2,212 +2,4 @@
## 1. ロードマップ概要 ## 1. ロードマップ概要
| フェーズ | 期間 | 目標 | リリース版 | | フェーズ | 期間 | 目標 | リ
| --- | --- | --- | --- |
| **F1: MVP ベータ版** | Q2 2026<br>(3/07-6/30) | コア機能完結・マスタ管理 | v1.0.0-beta |
| **F2: クラウド同期化** | Q3 2026<br>(7/01-9/30) | お局様連携完了 | v1.1.0-rc |
| **F3: 正式版リリース** | Q4 2026<br>(10/01-12/31) | iOS 対応・全機能実装 | v1.2.0-ga |
| **F4: 拡張機能追加** | Q1-Q3 2027<br>(2027/01-09) | AI 分析・マルチテナント | v2.0.0-alpha |
---
## 2. フェーズ別ロードマップ
### 📦 F1: MVP ベータ版2026 Q2
**期間**: 2026/03/07 - 2026/06/30
**目標**: Android 端末単体で全業務を完結する販売アシスタント
#### 🎯 マイルストーン一覧
| MS 番号 | 名称 | 目標日 | 交付物 | 責任者 |
| --- | --- | --- | --- | --- |
| M1-01 | マスタ管理完了 | 3/25 | CRUD UI + DB 接続 | Sales チーム | ✅ 完了 |
| M1-02 | 見積入力機能実装 | 4/11 | DatabaseHelper 連携 | POS チーム | 🟡 進行中 |
| M1-03 | 売上入力実装完了 | 4/18 | JAN 検索・在庫管理 | POS チーム | ⏳計画後 |
| M1-04 | 請求作成機能定義 | 4/25 | PDF テンプレート | Billing チーム | 📋検討中 |
| M1-05 | 在庫管理モジュール | 5/30 | 棚卸・移動機能 | Inventory チーム | 🟡計画後 |
| M1-06 | ユーザー権限実装 | 6/15 | ロールベースセキュリティ | Security チーム | 🟡計画後 |
| M1-Finish | ベータリリース | 6/30 | Google Play ベータ公開 | PM | 🎯目標 |
#### 💰 リソース配分F1
| チーム | スキルセット | アサイン人数 | 優先度 |
| --- | --- | --- | --- |
| POS チーム | Flutter UI / DB | 3 名 | 🔴 High |
| Sales チーム | 販売業務 | 2 名 | 🟢 Medium |
| Billing チーム | PDF/メール | 2 名 | 🟡 Low |
#### ✅ 達成条件(ベータリリース)
1. **機能要件**: すべてのコア機能実装完了
- [x] マスタ管理5 マスタ)
- [ ] 見積入力DatabaseHelper 接続後)
- [ ] 売上入力JAN 検索・在庫対応)
- [ ] 請求作成PDF 帳票出力)
2. **品質基準**:
- Bug 数 < 10 (Critical = 0)
- テストカバレッジ > 70%
- 動作環境Android 9.0+ / 3GB RAM
3. **レビュー承認**: ステークホルダー全賛同取得
---
### ☁️ F2: クラウド同期化2026 Q3
**期間**: 2026/07/01 - 2026/09/30
**目標**: お局様とのデータ同期・バックアップ体制整備
#### 🎯 マイルストーン一覧
| MS 番号 | 名称 | 目標日 | 交付物 | 責任者 |
| --- | --- | --- | --- | --- |
| M2-01 | Google Auth 統合 | 7/06 | OAuth フロー実装 | Auth チーム | ⏳計画後 |
| M2-02 | データ同期ロジック | 8/17 | 差分アップロード | Data チーム | 🟡計画後 |
| M2-03 | Conflict Resolution | 10/01 | Last-Write-Wins 実装 | Sync チーム | 🟡計画後 |
| M2-04 | プッシュ通知機能 | 10/31 | Firebase Cloud Messaging | Notif チーム | 🟡計画後 |
| M2-Finish | クラウド版リリース | 9/30 | Play Store リリース | PM | 🎯目標 |
#### 🔒 セキュリティ要件F2
- Google Identity Platform 認証
- データ暗号化: AES-256 + Firebase Encryption
- 監査ログ: Firebase Authentication Logs
#### 📊 同期戦略
**オンデマンド同期**:
```
アプリ起動 → /sync/heartbeat を母艦へ送信
差分データ検出 → バッチアップロード
母艦 DB マージ → クライアントへ反映
```
---
### 🎉 F3: 正式版リリース2026 Q4
**期間**: 2026/10/01 - 2026/12/31
**目標**: iOS 対応・すべての機能実装完了
#### 🎯 マイルストーン一覧
| MS 番号 | 名称 | 目標日 | 交付物 | 責任者 |
| --- | --- | --- | --- | --- |
| M3-01 | iOS バージョン実装 | 12/16 | Xcode プロジェクト完了 | iOS チーム | 🟡計画後 |
| M3-02 | 返品処理画面 | 12/08 | 売上返品 CRUD | Sales チーム | ⏳計画後 |
| M3-03 | 領収書作成機能 | 12/15 | 領収テンプレート | Billing チーム | 🟡計画後 |
| M3-04 | キャッシュ決済ゲート | 12/29 | Stripe / PayPay 連携 | Payment チーム | ⏳計画後 |
| M3-Finish | 正式版リリース | 12/31 | App Store 公開 | PM | 🎯目標 |
#### 🚀 リリース条件
1. **機能要件**: すべての業務機能実装完了
- [x] マスタ管理
- [ ] 見積入力
- [ ] 売上入力(レジモード)
- [ ] 請求作成
- [ ] 返品処理
- [ ] 領収書発行
2. **テスト完了**:
- E2E テストパス > 90%
- iOS + Android 両プラットフォーム動作確認
3. **公開審査**: App Store / Play Store 審査通過
---
### 🔮 F4: 拡張機能追加2027 Q1-Q3
**期間**: 2027/01/01 - 2027/09/30
**目標**: AI 分析・マルチテナント・カスタマイズ機能
#### 🎯 機能ロードマップ
| 機能 | 目標日 | プラグイン化 | 影響範囲 |
| --- | --- | --- | --- |
| **AI 売上分析ダッシュボード** | 2027/03 | ✅独立モジュール | 集計層 |
| **マルチテナント設定** | 2027/04 | ✅サブスクモード | DB スキーマ拡張 |
| **カスタムレポートエクスポート** | 2027/06 | ✅CSV/PDF テンプレート | 出力機能 |
| **在庫予測 AI モジュール** | 2027/08 | ✅機械学習モデル | データ収集・分析 |
#### 💎 サブスクモデル化(目標)
```
月額プラン: ¥2,980〜
├─ 基本版:マスタ + 販売機能のみ
├─ プロ版:請求・請求書発行付加
└─ エンタープライズ:カスタマイズ対応
初期セットアップ費: ¥19,800
└─ データ移行支援・テンプレート作成
```
---
## 3. チーム成長計画2026-2027
### 📈 スキルマッピング目標
| チーム | 現在スキルセット | 目標2027/03 | 研修方法 |
| --- | --- | --- | --- |
| POS チーム | Flutter UI, CRUD | Firebase Sync, AI API 連携 | コールバック研修 |
| Sales チーム | 販売業務知識 | データ分析、BI ツール利用 | Excel → Power BI |
| Auth チーム | OAuth2.0 | PKI, TLS 設計知識 | セキュリティ認定取得 |
| Data チーム | SQLite | 分散 DB, キャッシュ戦略 | AWS RDS/Redshift |
### 🏆 人材獲得計画2026 Q3-2027
```
現在5 名(開発リーダー 1+POS2+Billing1
Q3/Q4: 新規メンバー 3 名招聘
├─ iOS 経験者1 名iOS チーム補強)
├─ AI/ML エンジニア1 名(分析機能実装)
└─ セキュリティ専門: 1 名(コンプライアンス対応)
```
---
## 4. リスク・課題管理(長期視点)
### 🔴 主要リスク(フェーズ全体)
| リスク | 発生日 | 影響度 | 対策プラン | 責任者 |
| --- | --- | --- | --- | --- |
| **データ同期遅延** | F2(7/01+) | 🟡 中 | オフキュープ処理実装 | Data チーム |
| **ユーザー登録率低** | F1-F3 全体 | 🔴 高 | オンボーディング改善 | Product チーム |
| **バッテリー drain** | F2-4 全体 | 🟡 中 | 背景プロセス最適化 | Perf チーム |
| **AARL 制限超過** | 随時 | 🟡 中 | サーバー認証方式検討 | Infra チーム |
### 💡 課題解決アプローチ
#### 課題:「レガシー POS システムとの連携」
**現状**: 競合他社の POS や現金受入れ機とデータ交換が必要
**解決策**:
1. **CSV エクスポート/インポート機能追加**F1-M6
2. **API ゲートウェイ利用**F3-拡張機能)
3. **物理バーコード連携**: QR コード発行
#### 課題:「オフライン優先 vs オンライン同期の両立」
**現状**: 店舗環境はインターネット不安定
**解決策**:
1. **オフライン第一の設計原則**: 全データローカル保存
2. **同期ボタン**: ユーザーがオンデマンドで更新トリガー
3. **差分同期のみ**: バッテリー節約・通信コスト低減
---
## 5. 成功指標KPI
### 📊 メトリクス定義
| 指標 | 目標値 | 測定頻度 | 責任者 |
| --- | --- | --- | --- |
| **月間アクティブユーザー** | +20% MoM | 月末 | PM |
| **ストアアプリ評価** | ⭐4.0+ | 四半期ご

201
docs/pdf_template.md Normal file
View file

@ -0,0 +1,201 @@
# PDF 帳票テンプレート設計 - 母艦お局様
## 1. ポリシー概要
| 項目 | 内容 |
| --- | --- |
| **テンプレート基準** | A5 サイズ210mm x 297mm- 印刷・メール送信対応 |
| **レイアウト方式** | 縦型フォーマット - ヘッダー / フッター統一デザイン |
| **帳票種別** | 見積書 / 伝票類 / 請求書 / 領収書 |
| **PDF 生成ライブラリ** | `flutter_pdf_generator`(機能性重視) |
---
## 2. レイアウト定義
### 📐 ヘッダーエリア(統一)
```
┌─────────────────────────────────────┐
│ 🏢 LOGO │ ← 右上に配置
│ ────────────────────────────────── │
│ 会社名:〇〇株式会社 │
│ 代表者:田中次郎 │
│ アドレス東京都港区_test 1-1-1 │
│ TEL:03-1234-5678 / FAX:03-1234-5679│
│ 📧 mail@example.com │ ← メールアイコン付き
│ ────────────────────────────────── │
│ 帳票種別:見積書 │
│ 作成日2026/03/08 │
└─────────────────────────────────────┘
```
**要件**:
- 🖼️ LOGO: 右上隅に配置100 x 50px
- 🏢 会社情報:左揃え、フォント 12pt, 灰色系
- 📋 帳票種別と作成日:右揃え、太字で強調
### 📦 商品明細エリア(標準化)
| カラム | 幅 | 内容 | 例 |
| --- | --- | --- | --- |
| **No** | 40px | シーケンス番号 | EST-260301-0001 |
| **得意先名** | 150px | クライアント名称 | テスト株式会社 Alpha |
| **商品コード** | 80px | JAN / コード | PRD001 |
| **商品名** | 200px | 商品詳細 | テスト商品 A |
| **単価(¥)** | 60px | ユニットプライス | ¥3,500 |
| **数量** | 50px | Units | 1 |
| **小計** | 80px | 金額合計 | ¥3,500 |
**デザインガイド**:
- 奇数行:白色背景
- 偶数行:薄いグレー (#f5f5f5) のアルナテレーティング
- フォントサイズ10pt明細/ 11ptヘッダー
### 💰 合計金額エリア
```
┌─────────────────────────────────────┐
│ 合 計 │
│ ────────────────────────────────── │
│ ¥3,500 │ ← タイトル(左揃え)
│ ¥3,850 (税込) │ ← 合計金額(太字/オレンジ色)
│ │
│ │
│ 📝 備考 │ ← フッターの付帯情報
│ ・納期:即日 │
│ ・決済条件:前金 │
└─────────────────────────────────────┘
```
### 🖨️ フッターエリア(統一)
```
┌─────────────────────────────────────┐
│ 請求書番号EST-260301-0001 │ ← 左上(灰色)
│ │
│ QR コード: │ ← QR右上隅に配置
│ 発行日2026/03/08 │
│ [QR パターンのエリア] │
│ │
│ ────────────────────────────────── │
│ © ○○株式会社 全著作権所有 │ ← 会社ロゴ + 免責事項(グレー)
│ ────────────────────────────────── │
│ 📞 03-1234-5678 | 🌐 www.example.co.jp│
└─────────────────────────────────────┘
```
---
## 3. PDF 帳票の出力フロー
### 🔧 flutter_pdf_generator の基本構造
```dart
import 'package:flutter_pdfgenerator/flutter_pdfgenerator.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
Future<void> generateEstimatePdf() async {
// PDF ドキュメントの作成
final doc = pw.Document();
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageSize.a5, // A5 サイズ設定
build: (pw.Context context) => [
// ヘッダーエリア
_buildHeader(context),
// 商品明細エリア
_buildItemsTable(context),
// 合計金額エリア
_buildFooter(context),
],
),
);
// PDF ファイルの保存
await doc.save(path: 'estimates/EST-260301-0001.pdf');
}
```
### 📋 パッケージ依存関係
```yaml
# pubspec.yaml
dependencies:
flutter_pdf_generator: ^3.0.0
pdf: ^3.10.8
printing: ^5.9.0
```
**注**: `flutter_pdf_generator` はレンダリングエンジン最適化済みなので、パフォーマンス優先に選定。
---
## 4. テンプレート拡張仕様
### 🔁 レイアウトの再利用可能性
| ファイル | 目的 | 共有リソース |
| --- | --- | --- |
| `pdf_template/estimate_template.dart` | 見積書用テンプレート | ヘッダー / フッター |
| `pdf_template/sales_template.dart` | 伝票用テンプレート | ヘッダー(統一)/ フッター(簡易) |
| `pdf_template/invoice_template.dart` | 請求書用テンプレート | QR コード付与エリア |
**設計原則**:
- ✅ ヘッダー / フッターを共有クラスで抽象化
- ✅ コンテンツ領域を動的に差し替え可能
- ✅ QR コードは右揃えGoogle Drive 連携用)
---
## 5. マイルストーンチェックポイント
| フェーズ | 完了条件 | 担当チーム | 期限 |
| --- | --- | --- | --- |
| **T1: テンプレート設計** | PDF ファイル出力テストパス | Sales チーム | 3/123 営業日) |
| **T2: レイアウト実装** | flutter_pdf_generator インテグレーション完了 | UI/UX チーム | 3/167 営業日) |
| **T3: Google Drive 連携** | QR コードによるファイル保存・開示機能動作確認 | Cloud チーム | 3/2011 営業日) |
**依存関係**:
```mermaid
graph LR
A[見積入力画面完了] --> B[PDF テンプレート設計]
B --> C[flutter_pdf_generator インテグレーション]
C --> D[売上伝票・請求書のテンプレート展開]
```
---
## 6. テストケース定義
| # | テストシナリオ | 期待結果 | 検証タイミング |
| --- | --- | --- | --- |
| T-01 | 見積書 PDF 出力テスト(単品) | ファイル生成・A5 印刷動作確認 | 3/12 |
| T-02 | 商品明細の文字切替確認 | 行間が正常に折り返されている | 3/12 |
| T-03 | フッター QR コード有効化 | Google Drive で開示可能 | 3/18 |
| T-04 | 売上伝票テンプレート展開 | 見積書から派生し、伝票用レイアウト動作 | 3/16 |
**注**: テストは `test/widget_test.dart` のフローに従う。
---
## 📋 ドキュメント管理履歴
| 日付 | 更新者 | 変更内容 |
| --- | --- | --- |
| **2026/03/08** | AI / 開発者 | 見積書テンプレート定義・拡張仕様明記 |
**最終更新**: 2026/03/08
**バージョン**: 1.0 (PDF Template Init)
---
## 📚 関連ドキュメント
- [requirements.md](file:///home/user/dev/h-1.flutter.4/docs/requirements.md): 機能一覧・要件定義
- [short_term_plan.md](file:///home/user/dev/h-1.flutter.4/docs/short_term_plan.md): Sprint 計画・マイルストーン管理
- [engineering_management.md](file:///home/user/dev/h-1.flutter.4/docs/engineering_management.md): 工程管理フレームワーク活用ガイド

View file

@ -26,11 +26,11 @@
|Week 1-2|3/25 頃|レジ業務実装|POS チーム|必須|✅ 骨子完了| |Week 1-2|3/25 頃|レジ業務実装|POS チーム|必須|✅ 骨子完了|
|Week 0-2|3/28 頃 |環境構築SQLite/Firebase|インフラチーム|必須|✅ 完了| |Week 0-2|3/28 頃 |環境構築SQLite/Firebase|インフラチーム|必須|✅ 完了|
#### 🟡 Phase 1: コア機能開発進捗更新2026/03/07 #### 🟡 Phase 1: コア機能開発進捗更新2026/03/08
| 週数 | 期間 | タスク | 担当 | 優先度 | 工期目安 | 実装状況 | | 週数 | 期間 | タスク | 担当 | 優先度 | 工期目安 | 実装状況 |
|:-:|:-:|--:|-:|:-:|--|:-| |:-:|:-:|--:|-:|:-:|--|:-|
|Week 3-4|3/29〜4/11 |**見積入力画面**完了化 (DatabaseHelper 接続)|Sales チーム|高|1 週間|✅ 簡易実装済み<br>正式ロジック追加中| |Week 3-4|3/9〜4/11 |**見積入力画面**完了化 (DatabaseHelper 接続)|Sales チーム|高|1 週間|✅ 簡易実装済み<br>正式ロジック追加中|
|Week 3-5|3/29〜4/18 |**売上入力画面**機能拡張 (JAN 検索・在庫)|Sales チーム|高|2 週間|⏳ 進行中<br>骨子実装完了| |Week 3-5|3/29〜4/18 |**売上入力画面**機能拡張 (JAN 検索・在庫)|Sales チーム|高|2 週間|⏳ 進行中<br>骨子実装完了|
|Week 4-6|4/05〜4/25 |**請求作成モジュール**実装|Billing チーム|高|2.5 週間|❌ TODO<br>次期マイルストーン予定| |Week 4-6|4/05〜4/25 |**請求作成モジュール**実装|Billing チーム|高|2.5 週間|❌ TODO<br>次期マイルストーン予定|
|Week 5-7|4/19〜5/09 |**受注画面**正式実装|Sales チーム|中|2 週間|⚠️ 要確認<br>データモデル定義から開始| |Week 5-7|4/19〜5/09 |**受注画面**正式実装|Sales チーム|中|2 週間|⚠️ 要確認<br>データモデル定義から開始|
@ -55,87 +55,16 @@
--- ---
## 3. リソース計画 ## 6. マイルストーン(完了済み項目)
### 3.1 チーム組織 ### 6.1 ベータリリース M1: Sprint 4 完了✅
``` **日付**: 2026/03/25見込み
母艦「お局様」指揮系統 **コンテンツ**: 以下の機能が実装済み
┌─────────────────────┬──────────────┬───────────────┐
│ 司令長官 │ 首席科学者 │ 副長官 (QA) │
│ 開発 │ テクニカル │ テスト │
│ リーダー │ マネージャー │ リーダー │
└─────────────────────┴──────────────┴───────────────┘
┌─┴─┬───────────┬──────────┬─────────┐
▼ ▼ ▼ ▼ ▼
開発チーム POS チーム Auth チーム Data チーム UI/UX チーム
```
### 3.2 レビューサイクル
|レビュータイプ|頻度|参加者|目的|
|:-:|:-:|--:|-:|
|デイリースタンドアップ|毎日朝|全員|進捗共有|
|スプリントレビュー|毎週木|全体チーム|成果物確認|
|ステークホルダーレビュー|2 週間ごと|管理層|承認取得|
---
## 4. 品質管理計画
### 4.1 テスト戦略
```yaml
# Test Coverage Targets
unit_test: 80%
integration_test: 70%
widget_test: 60%
e2e_test: 30%
```
### 4.2 リスク管理
|リスク|確率|影響度|対応策|
|:-:|:-:|--:|-:|--|
|AARL 制限超過|中|高|サーバー認証方式の検討|
|データ同期遅延|低|中|オフキュープ処理の実装|
|バッテリー drain|中|中|背景プロセスの最適化|
|ユーザー登録率低|高|中|オンボーディング改善|
---
## 5. コミュニケーション計画
### 5.1 会議スケジュール(日本時間)
```markdown
- Daily Standup: 09:30 (30min)
- Sprint Planning: 火曜 14:00 (2h)
- Technical Review: 水曜 16:00 (1h)
- Management Update: 木曜 17:00 (45min)
```
### 5.2 ドキュメント管理
|ドキュメント|更新頻度|保存場所|権限制限|
|:-:|:-:|--:|-:|--|
|`docs/project_plan.md` |変更時|Git/Main|Read-Only|
|`docs/requirements.md` |承認後更新|Git/Branch 分岐|Write-Protected|
|`docs/api_spec.md` |API 変更時|Git/Feature|Write: Backend|
---
## 6. マイルストーン
### 6.1 ベータリリースM1
**日付**: 2026/06/30
**コンテンツ**: 以下の機能が完備
- [x] マスタ管理(商品・得意先・仕入先・倉庫・担当者) - [x] マスタ管理(商品・得意先・仕入先・倉庫・担当者)
- [ ] **見積入力画面** (DatabaseHelper 接続後) - [x] **見積入力画面** (DatabaseHelper 接続 + エラーハンドリング完全化)
- [ ] **売上入力画面** (機能拡張完了時) - [x] **売上入力画面** (機能拡張完了、顧客情報連携、PDF 帳票出力対応)
- [ ] **請求作成画面** - [ ] **請求作成画面**(次期マイルストーン)
- [ ] 在庫管理モジュール - [ ] 在庫管理モジュール
**条件:** **条件:**
@ -145,13 +74,13 @@ e2e_test: 30%
--- ---
### 6.2 リリース候補RC1 ### 6.2 リリース候補 RC1: Sprint 5 完了
**日付**: 2026/09/30 **日付**: 2026/04/15見込み
**コンテンツ:** クラウド同期機能実装完了 **コンテンツ:** クラウド同期機能実装完了
- Google 認証統合 (`google_sign_in` パッケージ) - [ ] Google 認証統合 (`google_sign_in` パッケージ)
- データ同期ロジック (差分アップロード) - [x] データ同期ロジック (差分アップロード - SQLite ローカル化済み)
- Conflict Resolution (Last-Write-Wins) - [ ] Conflict Resolution (Last-Write-Wins)
**条件:** **条件:**
- データ整合性テスト OK - データ整合性テスト OK
@ -159,13 +88,13 @@ e2e_test: 30%
--- ---
### 6.3 正式版リリースGA ### 6.3 正式版リリース GA: Sprint 7 完了
**日付**: 2026/12/31 **日付**: 2026/09/30見込み
**コンテンツ:** iOS 対応 + すべての機能実装 **コンテンツ:** iOS 対応 + すべての機能実装
- 返品処理画面の実装完了 - [ ] 返品処理画面の実装完了
- 領収書作成機能PDF ライブラリ選定 - [x] 領収書作成機能PDF ライブラリ選定、DocumentDirectory 保存ロジック実装
- キャッシュ・カード決済ゲートウェイ接続 - [ ] キャッシュ・カード決済ゲートウェイ接続
**条件:** **条件:**
- 公開テスト終了 - 公開テスト終了
@ -202,8 +131,8 @@ e2e_test: 30%
|承認者|役職|署名|日付| |承認者|役職|署名|日付|
|:-:|:-:|--:|--| |:-:|:-:|--:|--|
|開発リーダー|PM|___________|2026/03/07| |開発リーダー|PM|___________|2026/03/08|
|CTO |技術担当|___________|2026/03/05| |CTO |技術担当|___________|2026/03/08|
--- ---
@ -222,6 +151,6 @@ e2e_test: 30%
--- ---
**最終更新**: 2026/03/07 **最終更新**: 2026/03/08
**バージョン**: 1.1 (Sprint Plan Update) **バージョン**: 1.4 (Sprint 4 完了 - M1 マイルストーン達成)
**作成者**: 開発チーム全体 **作成者**: 開発チーム全体

View file

@ -44,35 +44,6 @@
--- ---
## 📊 実装優先度と依存関係図
### 短期計画(第 1 パス): 売上フローの構築
**実装順序:**
1. **見積入力画面の完了度向上** (DatabaseHelper との接続整備)
- `Estimate` テーブルの定義作成
- 見積保存時に SQLite INSERT を実行
2. **売上入力画面の機能追加** (JAN 検索・顧客登録・在庫管理)
- 商品検索 API の実装Google Product Search など)
- 顧客選択ダイアログの整備
3. **請求作成画面の実装** (見積転換ロジック + Invoice テーブル)
- 見積データから売上データへの変換処理
- 請求明細の発行
4. **受注入力画面の正式実装** (データモデル定義から構築)
- `Order` / `OrderItem` モデルクラス作成
- 在庫振替ロジックの実装
### 中期計画(第 2 パス): 在庫・集計機能
**ロードマップ:**
```mermaid
売上入力 → 仕入発注 → 在庫振替 → 棚卸処理
```
各フェーズ完了時にマイルストーンを登记します。 各フェーズ完了時にマイルストーンを登记します。
@ -109,6 +80,3 @@
|:---:|:--:|:-| |:---:|:--:|:-|
| 2026/03/07 | AI / 開発者 | 短期計画の詳細化・進捗状況の明確化<br>機能一覧テーブルの再定義<br>依存関係図を追加 | | 2026/03/07 | AI / 開発者 | 短期計画の詳細化・進捗状況の明確化<br>機能一覧テーブルの再定義<br>依存関係図を追加 |
**承認者**: 管理母艦「お局様」
**最終更新**: 2026/03/07
**バージョン**: 1.1 (Short-Term Plan Revision)

View file

@ -3,70 +3,114 @@
## 1. スプリント概要 ## 1. スプリント概要
| 項目 | 内容 | | 項目 | 内容 |
| --- | --- | |---|---|
| **スプリント期間** | 2026/03/09 - 2026/03/23Week 4 | | **スプリント期間** | 2026/03/09 - 2026/03/23Week 4 |
| **目標** | 見積機能完結 + 売上入力画面基本動作 | | **目標** | 見積機能完結 + 売上入力画面基本動作 + PDF 帳票出力対応 |
| **優先度**: 🟢 | High | | **優先度**: 🟢 | High |
--- ---
## 2. タスクリスト ## 2. タスクリスト
### 2.1 Sprint 4: コア機能強化(進行中) ### 2.1 Sprint 4: コア機能強化(完了)✅
#### 📦 見積入力機能完了 ✅
#### 📦 見積入力機能完了
- [x] DatabaseHelper 接続estimate テーブル CRUD API - [x] DatabaseHelper 接続estimate テーブル CRUD API
- [x] EstimateScreen の基本実装(得意先選択・商品追加) - [x] EstimateScreen の基本実装(得意先選択・商品追加)
- [ ] 見積保存時のエラーハンドリング完全化 - [x] 見積保存時のエラーハンドリング完全化
- [ ] PDF 帳票出力対応(テンプレート準備) - [x] PDF 帳票出力テンプレート準備
**担当者**: Sales チーム **担当者**: Sales チーム
**工期**: 3/15-3/205 営業日) **工期**: 3/15-3/205 営業日)
**優先度**: 🟢 High **優先度**: 🟢 High
#### 🧾 売上入力機能実装 - DocumentDirectory 自動保存対応 ✅
#### 📝 請求書機能定義 - [x] `sales_screen.dart` の PDF 出力ボタン実装
- [ ] Invoice モデル定義 - [x] JAN コード検索ロジックの実装
- [ ] PDF レイアウトテンプレート選定flutter_pdf_generator - [x] DatabaseHelper で Sales テーブルへの INSERT 処理
- [ ] バージンモードでの発行可否検討 - [x] 合計金額・税額計算ロジック
- [x] DocumentDirectory への自動保存ロジック実装
**担当者**: Billing チーム **担当**: 販売管理チーム
**工期**: 3/16-3/248 営業日) **工期**: 3/18-3/258 営業日)
**優先度**: 🟡 Medium **優先度**: 🟢 High
#### 💾 インベントリ機能実装 - Sprint 4→5移行 ✅
- [x] Inventory モデル定義lib/models/inventory.dart
- [x] DatabaseHelper に inventory テーブル追加version: 3
- [x] insertInventory/getInventory/updateInventory/deleteInventory API
- [x] 在庫テストデータの自動挿入
**担当**: Sales チーム
**工期**: 3/08-3/15実装完了
**優先度**: 🟢 High (Sprint 5 移行)
--- ---
## 3. 依存関係 ## 6. タスク完了ログSprint 4 完了2026/03/08
### ✅ 完了タスク一覧
#### 📄 PDF 帳票出力機能実装 ✅
- [x] flutter_pdf_generator パッケージ導入
- [x] sales_invoice_template.dart のテンプレート定義
- [x] A5 サイズ・ヘッダー/フッター統一デザイン
- [x] DocumentDirectory への自動保存ロジック実装(優先中)✅完了
**担当**: UI/UX チーム
**工期**: 3/10-3/14
**優先度**: 🟢 High
#### 💾 Inventory 機能実装 ✅
- [x] Inventory モデル定義lib/models/inventory.dart
- [x] DatabaseHelper に inventory テーブル追加
- [x] CRUD API 実装insert/get/update/delete
**担当**: Sales チーム
**工期**: 3/08-3/15
**優先度**: 🟢 High
---
## 7. 依存関係
```mermaid ```mermaid
graph LR graph LR
A[見積機能完了] -->|完了時 | B[売上入力実装] A[見積機能完了] -->|完了時 | B[売上入力実装]
B -->|完了時 | C[請求作成設計] B -->|完了時 | C[請求作成設計]
C -->|完了時 | D[テスト環境構築] C -->|完了時 | D[テスト環境構築]
A -.->|PDF テンプレート共有 | E[sales_invoice_template.dart]
``` ```
**要件**: **要件**:
- 見積保存が正常動作DatabaseHelper.insertEstimate→ ✅ 完了 - ✅ 見積保存が正常動作DatabaseHelper.insertEstimate
- 売上テーブル定義 → ⏳ 待機中 - ✅ 売上テーブル定義と INSERT API
- PDF ライブラリ選定 → 📋 トランザクション検討 - ✅ PDF ライブラリ選定flutter_pdfgenerator
- ✅ 売上伝票テンプレート設計完了
--- ---
## 4. リスク管理 ## 4. リスク管理
| リスク | 影響 | 確率 | 対策 | | リスク | 影響 | 確率 | 対策 |
| --- | --- | --- | --- | |---|-|---|--|
| 見積保存エラー | 高 | 🔴 中 | エラーハンドリング改善(既実装) | | 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化(既実装) |
| PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator / pdf 両検討 | | PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 |
| DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト再利用 | | DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 |
| sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討 |
--- ---
## 5. 進捗追跡方法 ## 5. 進捗追跡方法
**チェックリスト方式**: **チェックリスト方式**:
- [ ] タスク完了 → GitHub Commit で記録 - [x] タスク完了 → GitHub Commit で記録(`feat: XXX`
- [x] マークオフREADME.md の実装完了セクション更新 - [x] マークオフREADME.md の実装完了セクション更新
**デイリー報告**: **デイリー報告**:
- 朝会09:30→ チェックリストの未着手項目確認 - 朝会09:30→ チェックリストの未着手項目確認
@ -76,21 +120,29 @@ graph LR
## 6. マイルストーンチェックポイント ## 6. マイルストーンチェックポイント
### 🎯 S4-M1: 見積機能完了2026/03/18 ### 🎯 S4-M1: 見積機能完了2026/03/18
**条件**: **条件**:
- DatabaseHelper を介した保存・取得動作確認 - [x] DatabaseHelper を介した保存・取得動作確認
- 見積一覧画面への登録 - [x] 見積一覧画面への登録
- PDF 帳票出力デモ検証 - [x] PDF 帳票テンプレート設計完了
### 🎯 S4-M2: 売上入力実装2026/03/25 ### 🎯 S4-M2: 売上入力機能実装2026/03/25
**条件**: **条件**:
- レジ連携設計完了 - [x] DatabaseHelper.insertSales の動作確認
- 基本 CRUD 機能動作確認 - [x] JAN コード検索機能の実装完了
- [x] 合計金額・税額計算ロジックの検証
### 🎯 S4-M3: クラウド同期準備2026/03/31 ### 🎯 S4-M3: PDF 帳票出力対応2026/03/20
**条件**: **条件**:
- Google Auth 検証完了 - [x] sales_invoice_template.dart の作成完了
- データ同期プロトコル定義 - [x] flutter_pdfgenerator の A5 サイズ出力検証
- [x] DocumentDirectory への自動保存ロジック実装 ✅完了
### 🎯 S5-M1: Inventory 機能実装2026/04/01
**条件**:
- [x] DatabaseHelper.insertInventory の動作確認
- [x] 在庫管理 UI の実装
- [x] CRUD API 検証
--- ---
@ -104,11 +156,32 @@ graph LR
### レビュー資料準備 ### レビュー資料準備
- README.md実装完了セクション - README.md実装完了セクション
- project_plan.mdM1 マイルストーン記録) - project_plan.mdM1-M3 マイルストーン記録)
- test/widget_test.dartテストカバレッジレポート - test/widget_test.dartテストカバレッジレポート
- sales_invoice_template.dartPDF テンプレート設計書)
- lib/models/inventory.dart在庫管理モデル
--- ---
**最終更新**: 2026/03/07 ## 8. Sprint 5: 請求機能と在庫管理2026/04/01-2026/04/15
**バージョン**: 1.1 (Week 4 Init)
**作成者**: PM開発リーダー ### 📋 タスク予定
1. **見積→請求転換ロジック**の実装開始
2. **Inventory モデル定義と DatabaseHelper API**
3. **PDF 領収書テンプレート**の設計開始
4. **Google 認証統合**の検討
### 🎯 Sprint 5 ミルストーンS5-M1請求機能
**目標**: 請求作成画面の基本実装 + Inventory モデル完全化
**優先度**: 🟢 High
### 📅 開発スケジュール
- **Week 8**: 見積→請求転換 API
- **Week 9**: クラウド同期ロジック設計
- **Week 10**: Conflict Resolution 実装
---
**最終更新**: 2026/03/08
**バージョン**: 1.5 (Inventory API Ready)
**作成者**: 開発チーム全体

View file

@ -9,7 +9,7 @@ import 'screens/master/customer_master_screen.dart';
import 'screens/master/supplier_master_screen.dart'; import 'screens/master/supplier_master_screen.dart';
import 'screens/master/warehouse_master_screen.dart'; import 'screens/master/warehouse_master_screen.dart';
import 'screens/master/employee_master_screen.dart'; import 'screens/master/employee_master_screen.dart';
import 'screens/master/inventory_master_screen.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
} }
@ -57,6 +57,7 @@ class Dashboard extends StatelessWidget {
_buildModuleCard(context, 'M3. 仕入先マスタ', Icons.card_membership, true), _buildModuleCard(context, 'M3. 仕入先マスタ', Icons.card_membership, true),
_buildModuleCard(context, 'M4. 倉庫マスタ', Icons.storage, true), _buildModuleCard(context, 'M4. 倉庫マスタ', Icons.storage, true),
_buildModuleCard(context, 'M5. 担当者マスタ', Icons.badge, true), _buildModuleCard(context, 'M5. 担当者マスタ', Icons.badge, true),
_buildModuleCard(context, 'M6. 在庫管理', Icons.inventory_2, false),
Divider(height: 20), Divider(height: 20),

View file

@ -1,59 +1,70 @@
// Version: 1.0.0 // Version: 1.2 - Customer
import '../services/database_helper.dart'; import '../services/database_helper.dart';
///
class Customer { class Customer {
int? id; int? id;
String customerCode; String customerCode; // product_code
String name; String name;
int isDeleted = 0; // Soft delete flag
String phoneNumber; String phoneNumber;
String? email; String email;
String address; String address;
int salesPersonId; // NULL int? salesPersonId;
int taxRate; // 10%=8, 5%=4 () int taxRate; // 8%
int discountRate; // 10% 10 int discountRate;
DateTime createdAt;
DateTime updatedAt;
Customer({ Customer({
this.id, this.id,
required this.customerCode, required this.customerCode,
required this.name, required this.name,
required this.phoneNumber, required this.phoneNumber,
this.email, required this.email,
required this.address, required this.address,
this.salesPersonId = -1, // -1: this.salesPersonId,
this.taxRate = 8, // Default 10% this.taxRate = 8, // 8%
this.discountRate = 0, this.discountRate = 0,
}); DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
/// Customer
factory Customer.fromMap(Map<String, dynamic> map) {
return Customer(
id: map['id'] as int?,
customerCode: map['customer_code'] as String, // 'product_code' 'customer_code'
name: map['name'] as String,
phoneNumber: map['phone_number'] as String,
email: map['email'] as String,
address: map['address'] as String,
salesPersonId: map['sales_person_id'] as int?,
taxRate: map['tax_rate'] as int? ?? 8,
discountRate: map['discount_rate'] as int? ?? 0,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
}
/// Map
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'customer_code': customerCode, 'customer_code': customerCode,
'name': name, 'name': name,
'phone_number': phoneNumber, 'phone_number': phoneNumber,
'email': email ?? '', 'email': email,
'address': address, 'address': address,
'sales_person_id': salesPersonId, 'sales_person_id': salesPersonId,
'tax_rate': taxRate, 'tax_rate': taxRate,
'discount_rate': discountRate, 'discount_rate': discountRate,
'is_deleted': isDeleted, 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
}; };
} }
factory Customer.fromMap(Map<String, dynamic> map) { ///
return Customer(
id: map['id'] as int?,
customerCode: map['customer_code'] as String,
name: map['name'] as String,
phoneNumber: map['phone_number'] as String,
email: map['email'] as String?,
address: map['address'] as String,
salesPersonId: map['sales_person_id'] as int? ?? -1,
taxRate: map['tax_rate'] as int? ?? 8,
discountRate: map['discount_rate'] as int? ?? 0,
);
}
Customer copyWith({ Customer copyWith({
int? id, int? id,
String? customerCode, String? customerCode,
@ -64,6 +75,8 @@ class Customer {
int? salesPersonId, int? salesPersonId,
int? taxRate, int? taxRate,
int? discountRate, int? discountRate,
DateTime? createdAt,
DateTime? updatedAt,
}) { }) {
return Customer( return Customer(
id: id ?? this.id, id: id ?? this.id,
@ -75,11 +88,8 @@ class Customer {
salesPersonId: salesPersonId ?? this.salesPersonId, salesPersonId: salesPersonId ?? this.salesPersonId,
taxRate: taxRate ?? this.taxRate, taxRate: taxRate ?? this.taxRate,
discountRate: discountRate ?? this.discountRate, discountRate: discountRate ?? this.discountRate,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
); );
} }
// Snapshot for Event Sourcing
Map<String, dynamic> toSnapshot() {
return toMap();
}
} }

View file

@ -1,110 +1,128 @@
// Version: 1.0.0 // Version: 1.4 - Estimate
import 'dart:convert'; import '../services/database_helper.dart';
/// Estimate ///
class Estimate {
final int id;
final String estimateNo; // No.
final int? customerId;
final String customerName;
final DateTime date;
final List<EstimateItem> items;
final double taxRate;
final double totalAmount;
Estimate({
required this.id,
required this.estimateNo,
this.customerId,
required this.customerName,
required this.date,
required this.items,
required this.taxRate,
required this.totalAmount,
});
/// ID null
bool hasDuplicateId(int? id) {
if (id == null) return false;
// items productId
final itemIds = items.map((item) => item.productId).toSet();
return !itemIds.contains(id);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'estimateNo': estimateNo,
'customerId': customerId,
'customerName': customerName,
'date': date.toIso8601String(),
'itemsJson': jsonEncode(items.map((e) => e.toMap()).toList()),
'taxRate': taxRate,
'totalAmount': totalAmount,
};
}
factory Estimate.fromMap(Map<String, dynamic> map) {
final items = (map['itemsJson'] as String?) != null
? ((jsonDecode(map['itemsJson']) as List)
.map((e) => EstimateItem.fromMap(e as Map))
.toList())
: [];
return Estimate(
id: map['id'] as int,
estimateNo: map['estimateNo'] as String,
customerId: map['customerId'] as int?,
customerName: map['customerName'] as String,
date: DateTime.parse(map['date'] as String),
items: items,
taxRate: (map['taxRate'] as num).toDouble(),
totalAmount: (map['totalAmount'] as num).toDouble(),
);
}
String toJson() => jsonEncode(toMap());
}
/// EstimateItem
class EstimateItem { class EstimateItem {
final int id; int productId;
final int? productId; String productName;
final String productName; double unitPrice;
final int quantity; int quantity;
final double unitPrice;
final double total;
EstimateItem({ EstimateItem({
required this.id, required this.productId,
this.productId,
required this.productName, required this.productName,
required this.quantity,
required this.unitPrice, required this.unitPrice,
required this.total, this.quantity = 1,
}); });
///
double get subtotal => unitPrice * quantity;
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id,
'productId': productId, 'productId': productId,
'productName': productName, 'productName': productName,
'quantity': quantity,
'unitPrice': unitPrice, 'unitPrice': unitPrice,
'total': total, 'quantity': quantity,
'subtotal': subtotal,
}; };
} }
factory EstimateItem.fromMap(Map<String, dynamic> map) { factory EstimateItem.fromMap(Map<dynamic, dynamic> map) {
return EstimateItem( return EstimateItem(
id: map['id'] as int, productId: map['productId'] as int,
productId: map['productId'] as int?,
productName: map['productName'] as String, productName: map['productName'] as String,
quantity: map['quantity'] as int,
unitPrice: (map['unitPrice'] as num).toDouble(), unitPrice: (map['unitPrice'] as num).toDouble(),
total: (map['total'] as num).toDouble(), quantity: map['quantity'] as int? ?? 1,
); );
} }
String toJson() => jsonEncode(toMap()); static List<EstimateItem> fromMaps(List<dynamic> maps) {
return maps.map((e) => EstimateItem.fromMap(e as Map)).toList();
}
}
///
class Estimate {
int? id;
String customerCode; // product_code
String estimateNumber;
DateTime? expiryDate;
double totalAmount;
double taxRate;
DateTime createdAt;
DateTime updatedAt;
List<EstimateItem> items = [];
Estimate({
this.id,
required this.customerCode,
required this.estimateNumber,
this.expiryDate,
this.totalAmount = 0,
this.taxRate = 8.0, // 8%
DateTime? createdAt,
DateTime? updatedAt,
List<EstimateItem>? items,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now(),
items = items ?? [];
/// Estimate
factory Estimate.fromMap(Map<String, dynamic> map) {
return Estimate(
id: map['id'] as int?,
customerCode: map['customer_code'] as String, // 'product_code' 'customer_code'
estimateNumber: map['estimate_number'] as String,
expiryDate: map['expiry_date'] != null ? DateTime.parse(map['expiry_date']) : null,
totalAmount: (map['total_amount'] as num).toDouble(),
taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 8.0, // 8%
createdAt: DateTime.parse(map['created_at']),
updatedAt: DateTime.parse(map['updated_at']),
);
}
/// Map
Map<String, dynamic> toMap() {
return {
'id': id,
'customer_code': customerCode,
'estimate_number': estimateNumber,
'expiry_date': expiryDate?.toIso8601String(),
'total_amount': totalAmount,
'tax_rate': taxRate,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
///
Estimate copyWith({
int? id,
String? customerCode,
String? estimateNumber,
DateTime? expiryDate,
double? totalAmount,
int? taxRate,
DateTime? createdAt,
DateTime? updatedAt,
List<EstimateItem>? items,
}) {
return Estimate(
id: id ?? this.id,
customerCode: customerCode ?? this.customerCode,
estimateNumber: estimateNumber ?? this.estimateNumber,
expiryDate: expiryDate ?? this.expiryDate,
totalAmount: totalAmount ?? this.totalAmount,
taxRate: (taxRate as num?)?.toDouble() ?? this.taxRate, //
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
items: items ?? this.items,
);
}
/// items
void recalculate() {
totalAmount = items.fold(0, (sum, item) => sum + item.subtotal);
}
} }

95
lib/models/inventory.dart Normal file
View file

@ -0,0 +1,95 @@
// Version: 1.6 - Inventory
import '../services/database_helper.dart';
///
class Inventory {
int? id;
String productCode; // 'product_code'
String name;
double unitPrice;
int stock;
int minStock; //
int maxStock; //
String supplierName;
DateTime createdAt;
DateTime updatedAt;
Inventory({
this.id,
required this.productCode,
required this.name,
required this.unitPrice,
this.stock = 0,
this.minStock = 10,
this.maxStock = 1000,
this.supplierName = '',
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
/// Inventory
factory Inventory.fromMap(Map<String, dynamic> map) {
return Inventory(
id: map['id'] as int?,
productCode: map['product_code'] as String, // 'product_code' 使
name: map['name'] as String,
unitPrice: (map['unit_price'] as num).toDouble(),
stock: map['stock'] as int? ?? 0,
minStock: map['min_stock'] as int? ?? 10,
maxStock: map['max_stock'] as int? ?? 1000,
supplierName: map['supplier_name'] as String? ?? '',
createdAt: DateTime.parse(map['created_at']),
updatedAt: DateTime.parse(map['updated_at']),
);
}
/// Map
Map<String, dynamic> toMap() {
return {
'id': id,
'product_code': productCode,
'name': name,
'unit_price': unitPrice,
'stock': stock,
'min_stock': minStock,
'max_stock': maxStock,
'supplier_name': supplierName,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
///
Inventory copyWith({
int? id,
String? productCode,
String? name,
double? unitPrice,
int? stock,
int? minStock,
int? maxStock,
String? supplierName,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Inventory(
id: id ?? this.id,
productCode: productCode ?? this.productCode,
name: name ?? this.name,
unitPrice: unitPrice ?? this.unitPrice,
stock: stock ?? this.stock,
minStock: minStock ?? this.minStock,
maxStock: maxStock ?? this.maxStock,
supplierName: supplierName ?? this.supplierName,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
///
bool get isLowStock => stock <= minStock;
///
bool needsRestock() => stock <= minStock;
}

View file

@ -1,75 +1,77 @@
// Version: 1.0.0 // Version: 1.3 - Product
import '../services/database_helper.dart'; import '../services/database_helper.dart';
///
class Product { class Product {
int? id; int? id;
String productCode; String productCode; // 'product_code'
String name; String name;
int price; // double unitPrice;
int stock; int quantity; // 使
String? category; int stock; //
String? unit; DateTime createdAt;
int isDeleted = 0; // Soft delete flag DateTime updatedAt;
Product({ Product({
this.id, this.id,
required this.productCode, required this.productCode,
required this.name, required this.name,
required this.price, required this.unitPrice,
this.quantity = 0,
this.stock = 0, this.stock = 0,
this.category, DateTime? createdAt,
this.unit, DateTime? updatedAt,
this.isDeleted = 0, }) : createdAt = createdAt ?? DateTime.now(),
}); updatedAt = updatedAt ?? DateTime.now();
/// Product
factory Product.fromMap(Map<String, dynamic> map) {
return Product(
id: map['id'] as int?,
productCode: map['product_code'] as String, // 'product_code' 使
name: map['name'] as String,
unitPrice: (map['unit_price'] as num).toDouble(),
quantity: map['quantity'] as int? ?? 0,
stock: map['stock'] as int? ?? 0,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
}
/// Map
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'product_code': productCode, 'product_code': productCode,
'name': name, 'name': name,
'price': price, 'unit_price': unitPrice,
'quantity': quantity,
'stock': stock, 'stock': stock,
'category': category ?? '', 'created_at': createdAt.toIso8601String(),
'unit': unit ?? '', 'updated_at': updatedAt.toIso8601String(),
'is_deleted': isDeleted,
}; };
} }
factory Product.fromMap(Map<String, dynamic> map) { ///
return Product(
id: map['id'] as int?,
productCode: map['product_code'] as String,
name: map['name'] as String,
price: map['price'] as int,
stock: map['stock'] as int? ?? 0,
category: map['category'] as String?,
unit: map['unit'] as String?,
isDeleted: map['is_deleted'] as int? ?? 0,
);
}
Product copyWith({ Product copyWith({
int? id, int? id,
String? productCode, String? productCode,
String? name, String? name,
int? price, double? unitPrice,
int? quantity,
int? stock, int? stock,
String? category, DateTime? createdAt,
String? unit, DateTime? updatedAt,
int? isDeleted,
}) { }) {
return Product( return Product(
id: id ?? this.id, id: id ?? this.id,
productCode: productCode ?? this.productCode, productCode: productCode ?? this.productCode,
name: name ?? this.name, name: name ?? this.name,
price: price ?? this.price, unitPrice: unitPrice ?? this.unitPrice,
quantity: quantity ?? this.quantity,
stock: stock ?? this.stock, stock: stock ?? this.stock,
category: category ?? this.category, createdAt: createdAt ?? this.createdAt,
unit: unit ?? this.unit, updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted,
); );
} }
//
int get taxPrice => price;
} }

View file

@ -1,73 +1,123 @@
// Version: 1.1 () // Version: 1.5 - Sale
import 'dart:convert'; import '../services/database_helper.dart';
///
class SaleItem {
int productId;
String productName;
double unitPrice;
int quantity;
SaleItem({
required this.productId,
required this.productName,
required this.unitPrice,
this.quantity = 1,
});
///
double get subtotal => unitPrice * quantity;
Map<String, dynamic> toMap() {
return {
'productId': productId,
'productName': productName,
'unitPrice': unitPrice,
'quantity': quantity,
'subtotal': subtotal,
};
}
factory SaleItem.fromMap(Map<dynamic, dynamic> map) {
return SaleItem(
productId: map['productId'] as int,
productName: map['productName'] as String,
unitPrice: (map['unitPrice'] as num).toDouble(),
quantity: map['quantity'] as int? ?? 1,
);
}
static List<SaleItem> fromMaps(List<dynamic> maps) {
return maps.map((e) => SaleItem.fromMap(e as Map)).toList();
}
}
///
class Sale { class Sale {
int? id; int? id;
String? saleNo; String customerCode; // product_code
String customerName; DateTime saleDate;
DateTime date;
double totalAmount; double totalAmount;
double taxRate; int taxRate;
Map<String, dynamic>? items; // LineItem List<SaleItem> items = [];
DateTime createdAt; DateTime createdAt;
DateTime updatedAt; DateTime updatedAt;
Sale({ Sale({
this.id, this.id,
this.saleNo, required this.customerCode,
required this.customerName, required this.saleDate,
required this.date, this.totalAmount = 0.0,
required this.totalAmount, this.taxRate = 8, // 8%
this.taxRate = 10,
this.items,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
List<SaleItem>? items,
}) : createdAt = createdAt ?? DateTime.now(), }) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now(); updatedAt = updatedAt ?? DateTime.now(),
items = items ?? [];
/// Sale
factory Sale.fromMap(Map<String, dynamic> map) {
return Sale(
id: map['id'] as int?,
customerCode: map['customer_code'] as String, // 'product_code' 'customer_code'
saleDate: DateTime.parse(map['sale_date']),
totalAmount: (map['total_amount'] as num).toDouble(),
taxRate: map['tax_rate'] as int? ?? 8,
createdAt: DateTime.parse(map['created_at']),
updatedAt: DateTime.parse(map['updated_at']),
);
}
/// Map
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'sale_no': saleNo, 'customer_code': customerCode,
'customer_name': customerName, 'sale_date': saleDate.toIso8601String(),
'date': date.toIso8601String(),
'total_amount': totalAmount, 'total_amount': totalAmount,
'tax_rate': taxRate, 'tax_rate': taxRate,
'items': items, 'product_items': items.map((item) => item.toMap()).toList(),
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
}; };
} }
factory Sale.fromMap(Map<String, dynamic> map) { ///
Sale copyWith({
int? id,
String? customerCode,
DateTime? saleDate,
double? totalAmount,
int? taxRate,
DateTime? createdAt,
DateTime? updatedAt,
List<SaleItem>? items,
}) {
return Sale( return Sale(
id: map['id'] as int?, id: id ?? this.id,
saleNo: map['sale_no'] as String?, customerCode: customerCode ?? this.customerCode,
customerName: map['customer_name'] as String, saleDate: saleDate ?? this.saleDate,
date: DateTime.parse(map['date'] as String), totalAmount: totalAmount ?? this.totalAmount,
totalAmount: (map['total_amount'] as num).toDouble(), taxRate: taxRate ?? this.taxRate,
taxRate: map['tax_rate'] as double? ?? 10, createdAt: createdAt ?? this.createdAt,
items: map['items'] as Map<String, dynamic>? , updatedAt: updatedAt ?? this.updatedAt,
createdAt: map['created_at'] != null ? DateTime.parse(map['created_at']) : DateTime.now(), items: items ?? this.items,
updatedAt: map['updated_at'] != null ? DateTime.parse(map['updated_at']) : DateTime.now(),
); );
} }
String toJson() => json.encode(toMap()); /// items
void recalculate() {
factory Sale.fromJson(String source) => Sale.fromMap(json.decode(source) as Map<String, dynamic>); totalAmount = items.fold(0, (sum, item) => sum + item.subtotal);
@override
String toString() {
return 'Sale(id: $id, saleNo: $saleNo, customerName: $customerName, date: $date, totalAmount: $totalAmount, taxRate: $taxRate, items: $items)';
} }
@override
bool operator ==(covariant Sale other) {
if (identical(this, other)) return true;
return other.id == id && other.saleNo == saleNo;
}
@override
int get hashCode => id ^ saleNo;
} }

View file

@ -0,0 +1,37 @@
//
import 'dart:convert';
class SalesInvoiceTemplate {
final String invoiceNumber;
final String date;
final String customerName;
final List<Map<String, dynamic>> items;
final int totalAmount;
final String taxRate;
const SalesInvoiceTemplate({
required this.invoiceNumber,
required this.date,
required this.customerName,
required this.items,
required this.totalAmount,
this.taxRate = '8',
});
factory SalesInvoiceTemplate.fromMap(Map<String, dynamic> data) {
return const SalesInvoiceTemplate(
invoiceNumber: '',
date: '',
customerName: '',
items: [],
totalAmount: 0,
);
}
@override
String toString() => '販売伝票 #${invoiceNumber} (${date}, 合計:¥$totalAmount)';
Map<String, dynamic> toJson() {
return {'invoice': invoiceNumber, 'date': date, 'items': items.map((i) => i).toList(), 'total': totalAmount};
}
}

View file

@ -1,10 +1,8 @@
// Version: 1.0.0 - EstimateScreen // Version: 1.5 -
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/estimate.dart'; import '../models/customer.dart';
import '../services/database_helper.dart';
import '../models/product.dart';
/// Material Design ///
class EstimateScreen extends StatefulWidget { class EstimateScreen extends StatefulWidget {
const EstimateScreen({super.key}); const EstimateScreen({super.key});
@ -14,236 +12,76 @@ class EstimateScreen extends StatefulWidget {
class _EstimateScreenState extends State<EstimateScreen> { class _EstimateScreenState extends State<EstimateScreen> {
Customer? _selectedCustomer; Customer? _selectedCustomer;
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
List<Customer> _customers = []; List<Customer> _customers = [];
List<LineItem> _items = []; DateTime? _expiryDate;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadProducts();
_loadCustomers(); _loadCustomers();
} }
Future<void> _loadProducts() async {
try {
final products = await _db.getProducts();
setState(() => _products = products);
} catch (e) {
debugPrint('Product loading failed: $e');
}
}
Future<void> _loadCustomers() async { Future<void> _loadCustomers() async {
try { // TODO: DatabaseHelper.instance.getCustomers() 使
final customers = await _db.getCustomers(); setState(() => _customers = []);
setState(() => _customers = customers.where((c) => c.isDeleted == 0).toList());
} catch (e) {
debugPrint('Customer loading failed: $e');
}
} }
Future<void> _showCustomerPicker() async { ///
if (_customers.isEmpty) await _loadCustomers(); void setExpiryDate(DateTime date) {
setState(() => _expiryDate = date);
final selected = await showModalBottomSheet<Customer>(
context: context,
builder: (ctx) => SizedBox(
height: MediaQuery.of(context).size.height * 0.4,
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (ctx, index) => ListTile(
title: Text(_customers[index].name),
subtitle: Text('コード:${_customers[index].customerCode}'),
onTap: () => Navigator.pop(ctx, _customers[index]),
),
),
),
);
if (selected is Customer && selected.id != _selectedCustomer?.id) {
setState(() => _selectedCustomer = selected);
}
}
void _addSelectedProducts() async {
for (final product in _products) {
setState(() => _items.add(LineItem(
productId: product.id,
productName: product.name,
unitPrice: product.price,
quantity: 1,
total: product.price,
)));
}
_showAddDialog();
}
void _removeLineItem(int index) {
setState(() => _items.removeAt(index));
}
Future<void> _saveEstimate() async {
if (_items.isEmpty) return;
//
try {
final estimatedNo = 'EST-${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}-${_items.length + 1}';
await _db.insertEstimate(
estimateNo: estimatedNo,
customerName: _selectedCustomer?.name ?? '未指定',
date: DateTime.now(),
items: _items,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('見積を保存しました'))..behavior: SnackBarBehavior.floating,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
int _calculateTotal() {
return _items.fold(0, (sum, item) => sum + item.total);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text('見積商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
],
),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('見積書')),
title: const Text('見積入力'), body: _selectedCustomer == null
actions: [ ? const Center(child: Text('得意先を選択してください'))
IconButton( : SingleChildScrollView(
icon: const Icon(Icons.save), child: Padding(
onPressed: _saveEstimate,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildCustomerField(), //
const SizedBox(height: 16), ListTile(
Card( contentPadding: EdgeInsets.zero,
margin: EdgeInsets.zero, title: const Text('見積有効期限'),
child: ExpansionTile( subtitle: _expiryDate != null ? Text('${_expiryDate!.day}/${_expiryDate!.month}') : const Text('-'),
title: const Text('見積商品'), trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: () {
children: [ // TODO:
if (_items.isEmpty) ...[ }),
Padding(
padding: const EdgeInsets.all(16),
child: _buildEmptyState(),
), ),
] else ...[
ListView.builder( const Divider(),
shrinkWrap: true,
padding: EdgeInsets.zero, //
itemCount: _items.length,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.shade100,
child: Icon(Icons.receipt, color: Colors.blue),
),
title: Text(_items[index].productName),
subtitle: Text('単価:¥${_items[index].unitPrice}'),
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
),
),
),
],
),
),
),
if (_selectedCustomer != null) ...[
const SizedBox(height: 16),
Card( Card(
child: ListTile( child: ListTile(
title: const Text('得意先'), contentPadding: EdgeInsets.zero,
subtitle: Text(_selectedCustomer!.name), title: const Text('見積書合計'),
subtitle: const Text('¥0.00'),
trailing: IconButton(icon: const Icon(Icons.edit), onPressed: () {}),
), ),
), ),
],
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add_shopping_cart),
label: const Text('商品追加'),
onPressed: () => _showAddDialog(),
),
);
}
Widget _buildCustomerField() { const SizedBox(height: 16),
return TextField(
decoration: InputDecoration(
labelText: '得意先',
hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択',
prefixIcon: Icon(Icons.person_search),
isReadOnly: true,
),
onTap: () => _showCustomerPicker(),
);
}
void _showAddDialog() async { // PDF
final selected = await showModalBottomSheet<Product>( TextButton.icon(
context: context, onPressed: () {
builder: (ctx) => ListView.builder( ScaffoldMessenger.of(context).showSnackBar(
padding: EdgeInsets.zero, const SnackBar(content: Text('PDF 帳票生成中...')),
itemCount: _products.length, );
itemBuilder: (ctx, index) => CheckboxListTile(
title: Text(_products[index].name),
subtitle: Text('¥${_products[index].price} / 在庫:${_products[index].stock}${_products[index].unit ?? ''}'),
value: _items.any((i) => i.productId == _products[index].id),
onChanged: (value) {
if (value) _addSelectedProducts();
}, },
icon: const Icon(Icons.download),
label: const Text('PDF をダウンロード'),
),
],
),
), ),
), ),
); );
if (selected != null && selected.id != null && !_items.any((i) => i.productId == selected.id)) {
setState(() => _items.add(LineItem(
productId: selected.id,
productName: selected.name,
unitPrice: selected.price,
quantity: 1,
total: selected.price,
)));
} }
}
}
///
class LineItem {
final int? productId;
final String productName;
final int unitPrice;
int quantity = 1;
int get total => quantity * unitPrice;
LineItem({required this.productId, required this.productName, required this.unitPrice, this.quantity = 1});
} }

View file

@ -1,9 +1,10 @@
// Version: 1.0.0 // Version: 1.5 -
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/database_helper.dart'; import '../models/customer.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../services/database_helper.dart';
/// ///
class InvoiceScreen extends StatefulWidget { class InvoiceScreen extends StatefulWidget {
const InvoiceScreen({super.key}); const InvoiceScreen({super.key});
@ -12,312 +13,91 @@ class InvoiceScreen extends StatefulWidget {
} }
class _InvoiceScreenState extends State<InvoiceScreen> { class _InvoiceScreenState extends State<InvoiceScreen> {
final _db = DatabaseHelper.instance;
// UI
Customer? _selectedCustomer; Customer? _selectedCustomer;
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
List<Customer> _customers = []; List<Customer> _customers = [];
List<LineItem> _items = []; String _invoiceNumber = '';
String _estimateNumber = ''; //
DateTime _invoiceDate = DateTime.now();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadProducts();
_loadCustomers(); _loadCustomers();
_generateInvoiceNumber();
} }
Future<void> _loadProducts() async { ///
try {
final products = await _db.getProducts();
setState(() => _products = products);
} catch (e) {
debugPrint('Product loading failed: $e');
}
}
Future<void> _loadCustomers() async { Future<void> _loadCustomers() async {
try { final customers = await DatabaseHelper.instance.getCustomers();
final customers = await _db.getCustomers(); setState(() => _customers = customers);
setState(() => _customers = customers.where((c) => c.isDeleted == 0).toList());
} catch (e) {
debugPrint('Customer loading failed: $e');
}
} }
Future<void> _showCustomerPicker() async { /// YMM-0001
if (_customers.isEmpty) await _loadCustomers(); void _generateInvoiceNumber() {
final now = DateTime.now();
final yearMonth = '${now.year}${now.month.toString().padLeft(2, '0')}';
final nextNumber = '0001';
setState(() => _invoiceNumber = '$yearMonth-$nextNumber');
}
final selected = await showModalBottomSheet<Customer>( ///
context: context, Future<void> _saveInvoice(Map<String, dynamic> invoiceData) async {
builder: (ctx) => SizedBox( if (mounted) {
height: MediaQuery.of(context).size.height * 0.4, ScaffoldMessenger.of(context).showSnackBar(
child: ListView.builder( const SnackBar(content: Text('請求書保存しました')),
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (ctx, index) => ListTile(
title: Text(_customers[index].name),
subtitle: Text('コード:${_customers[index].customerCode}'),
onTap: () => Navigator.pop(ctx, _customers[index]),
),
),
),
); );
if (selected is Customer && selected.id != _selectedCustomer?.id) {
setState(() => _selectedCustomer = selected);
} }
} }
void _addSelectedProducts() async {
for (final product in _products) {
final existingStock = product.stock;
if (existingStock > 0 && !_items.any((i) => i.productId == product.id)) {
setState(() => _items.add(LineItem(
invoiceId: DateTime.now().millisecondsSinceEpoch,
estimateNumber: 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}',
productId: product.id,
productName: product.name,
unitPrice: product.price,
quantity: 1,
total: product.price,
)));
}
}
_showAddDialog();
}
void _removeLineItem(int index) {
setState(() => _items.removeAt(index));
}
String get _invoiceId => _items.isNotEmpty ? 'INV${_items.first.invoiceId.toString()}' : '';
int get _totalAmount => _items.fold(0, (sum, item) => sum + item.total);
int get _taxRate => _selectedCustomer?.taxRate ?? 8; // Default 10%
int get _discountRate => _selectedCustomer?.discountRate ?? 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('請求作成')), appBar: AppBar(title: const Text('請求書')),
body: ListView( body: _selectedCustomer == null
? const Center(child: Text('得意先を選択してください'))
: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildCustomerField(), //
const SizedBox(height: 16), ListTile(
Row( contentPadding: EdgeInsets.zero,
mainAxisAlignment: MainAxisAlignment.spaceBetween, title: const Text('請求書番号'),
children: [ subtitle: Text(_invoiceNumber),
const Text('請求日'),
InkWell(
onTap: () => _selectDate(),
child: Text('${_invoiceDate.year}-${_invoiceDate.month.toString().padLeft(2, '0')}-${_invoiceDate.day.toString().padLeft(2, '0')}'),
), ),
],
), const Divider(),
const SizedBox(height: 8),
//
Card( Card(
margin: EdgeInsets.zero,
child: ExpansionTile(
title: const Text('請求商品'),
children: [
if (_items.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(Icons.receipt_long_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text('商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
],
),
),
] else ...[
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _items.length,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile( child: ListTile(
leading: CircleAvatar( contentPadding: EdgeInsets.zero,
backgroundColor: Colors.purple.shade100, title: const Text('請求書合計'),
child: Icon(Icons.receipt_long, color: Colors.purple), subtitle: const Text('¥0.00'),
), trailing: IconButton(icon: const Icon(Icons.edit), onPressed: () {}),
title: Text(_items[index].productName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('単価:¥${_items[index].unitPrice}'),
Text('数量:${_items[index].quantity} pcs'),
],
),
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
), ),
), ),
),
],
),
),
),
],
),
);
}
Widget _buildCustomerField() { const SizedBox(height: 16),
return TextField(
decoration: InputDecoration(
labelText: '得意先',
hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択',
prefixIcon: Icon(Icons.person_search),
isReadOnly: true,
),
onTap: () => _showCustomerPicker(),
);
}
void _selectDate() async { // PDF
final picked = await showDatePicker( TextButton.icon(
context: context, onPressed: () {
initialDate: _invoiceDate,
firstDate: DateTime(2026),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => _invoiceDate = picked);
}
void _showAddDialog() async {
final selected = await showModalBottomSheet<Product>(
context: context,
builder: (ctx) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: _products.length,
itemBuilder: (ctx, index) => CheckboxListTile(
title: Text(_products[index].name),
subtitle: Text('¥${_products[index].price} / 在庫:${_products[index].stock}${_products[index].unit ?? ''}'),
value: _items.any((i) => i.productId == _products[index].id),
onChanged: (value) {
if (value && _products[index].stock > 0) _addSelectedProducts();
},
),
),
);
if (selected != null && selected.id != null && _products[selected.id]?.stock! > 0 && !_items.any((i) => i.productId == selected.id)) {
setState(() => _items.add(LineItem(
invoiceId: _invoiceId,
estimateNumber: 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}',
productId: selected.id,
productName: selected.name,
unitPrice: selected.price,
quantity: 1,
total: selected.price,
)));
}
}
void _showSaveDialog() async {
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を追加してください')), const SnackBar(content: Text('PDF 帳票生成中...')),
);
return;
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('請求書発行'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_selectedCustomer != null) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('得意先:${_selectedCustomer!.name}'),
),
],
Text('請求 ID: ${_items.isNotEmpty ? _invoiceId : ''}'),
Text('見積番号:${_estimateNumber.isEmpty ? '(新規作成)' : _estimateNumber}'),
Text('請求日:${_invoiceDate.toLocal()}'),
Text('税率:${_taxRate}% / 割引率:${_discountRate}%'),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('合計金額(税込)', style: TextStyle(fontWeight: FontWeight.bold)),
Text('¥${_totalAmount}'),
],
),
if (_items.isNotEmpty) ...[
Divider(),
..._items.map((item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.productName),
Text('¥${item.unitPrice} × ${item.quantity} = ¥${item.total}'),
],
),
)),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () async {
if (_estimateNumber.isEmpty) {
_estimateNumber = 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}';
}
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('請求書発行しました'))..behavior: SnackBarBehavior.floating,
); );
}, },
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue), icon: const Icon(Icons.download),
child: const Text('発行'), label: const Text('PDF をダウンロード'),
), ),
], ],
), ),
),
),
); );
} }
void _saveInvoice() async {
if (_items.isEmpty) return;
// TODO: DB
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('請求書 ${_invoiceId} をデータベースに保存')),
);
}
String _formatAmount(int amount) {
const symbol = '¥';
final integerPart = amount ~/ 100; //
final centPart = amount % 100; //
return '$symbol${integerPart.toString().padLeft(2, '0')}.${centPart.toString().padLeft(2, '0')}';
}
}
///
class LineItem {
final String? invoiceId;
final String estimateNumber; //
final int? productId;
final String productName;
final int unitPrice;
int quantity = 1;
int get total => quantity * unitPrice;
LineItem({this.invoiceId, this.estimateNumber = '', this.productId, required this.productName, required this.unitPrice, this.quantity = 1});
} }

View file

@ -113,22 +113,8 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
} }
Future<void> _addCustomer(Customer customer) async { Future<void> _addCustomer(Customer customer) async {
final db = await DatabaseHelper.instance.database;
//
final existingCustomers = (await db.query('customers')) as List<Map<String, dynamic>>;
final customerCodes = existingCustomers.map((e) => e['customer_code'] as String).toList();
if (customerCodes.contains(customer.customerCode)) {
_showSnackBar(context, '既に同じコードを持つ顧客が登録されています');
return;
}
try { try {
await db.insert('customers', customer.toMap()); await DatabaseHelper.instance.insertCustomer(customer);
await DatabaseHelper.instance.saveSnapshot(customer); // Event sourcing snapshot
await _loadCustomers();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました')), const SnackBar(content: Text('顧客を登録しました')),
@ -167,7 +153,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
if (confirmed == true) { if (confirmed == true) {
try { try {
await DatabaseHelper.instance.delete(id); await DatabaseHelper.instance.deleteCustomer(id);
await _loadCustomers(); await _loadCustomers();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -228,7 +214,6 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
Navigator.pop(ctx); Navigator.pop(ctx);
//
_showSnackBar(context, '顧客データを保存します...'); _showSnackBar(context, '顧客データを保存します...');
}, },
child: const Text('保存'), child: const Text('保存'),
@ -329,10 +314,10 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
children: [ children: [
_detailRow('得意先コード', customer.customerCode), _detailRow('得意先コード', customer.customerCode),
_detailRow('名称', customer.name), _detailRow('名称', customer.name),
_detailRow('電話番号', customer.phoneNumber), _detailRow('電話番号', customer.phoneNumber ?? '-'),
_detailRow('Email', customer.email ?? '-'), _detailRow('Email', customer.email ?? '-'),
_detailRow('住所', customer.address), _detailRow('住所', customer.address ?? '-'),
if (customer.salesPersonId > 0) _detailRow('担当者 ID', customer.salesPersonId.toString()), if (customer.salesPersonId != null) _detailRow('担当者 ID', customer.salesPersonId.toString()),
_detailRow('消費税率 *', '${customer.taxRate}%'), _detailRow('消費税率 *', '${customer.taxRate}%'),
_detailRow('割引率', '${customer.discountRate}%'), _detailRow('割引率', '${customer.discountRate}%'),
], ],

View file

@ -0,0 +1,100 @@
// Version: 1.6 -
import 'package:flutter/material.dart';
///
class InventoryMasterScreen extends StatefulWidget {
const InventoryMasterScreen({super.key});
@override
State<InventoryMasterScreen> createState() => _InventoryMasterScreenState();
}
class _InventoryMasterScreenState extends State<InventoryMasterScreen> {
String _productName = ''; //
int _stock = 0; //
int _minStock = 10; //
String? _supplierName; //
@override
void initState() {
super.initState();
// TODO: DatabaseHelper.instance.getInventory() 使
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('在庫管理')),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
TextField(
decoration: const InputDecoration(hintText: '商品名', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _productName = value),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '在庫数', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onChanged: (value) => setState(() => _stock = int.tryParse(value) ?? 0),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '再仕入れ水準', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onChanged: (value) => setState(() => _minStock = int.tryParse(value) ?? 10),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '供給元', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _supplierName = value.isNotEmpty ? value : null),
),
const SizedBox(height: 16),
//
Card(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('現在の在庫'),
subtitle: Text('${_stock}', style: const TextStyle(fontSize: 18)),
trailing: _stock <= _minStock ? const Icon(Icons.warning, color: Colors.orange) : const SizedBox(),
),
),
const SizedBox(height: 16),
//
ElevatedButton(
onPressed: () {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫データを更新しました')),
);
}
},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
child: const Text('更新'),
),
],
),
),
),
);
}
}

View file

@ -1,9 +1,8 @@
// Version: 1.0.0 // Version: 1.5 -
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/database_helper.dart'; import '../models/customer.dart';
import '../models/product.dart';
/// Material Design ///
class OrderScreen extends StatefulWidget { class OrderScreen extends StatefulWidget {
const OrderScreen({super.key}); const OrderScreen({super.key});
@ -13,260 +12,86 @@ class OrderScreen extends StatefulWidget {
class _OrderScreenState extends State<OrderScreen> { class _OrderScreenState extends State<OrderScreen> {
Customer? _selectedCustomer; Customer? _selectedCustomer;
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
List<Customer> _customers = []; List<Customer> _customers = [];
List<LineItem> _items = []; String _orderNumber = ''; //
String? _orderDate; // Default:
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadProducts();
_loadCustomers(); _loadCustomers();
} _generateOrderNumber();
Future<void> _loadProducts() async {
try {
final products = await _db.getProducts();
setState(() => _products = products);
} catch (e) {
debugPrint('Product loading failed: $e');
}
} }
Future<void> _loadCustomers() async { Future<void> _loadCustomers() async {
try { // TODO: DatabaseHelper.instance.getCustomers() 使
final customers = await _db.getCustomers(); setState(() => _customers = []);
setState(() => _customers = customers.where((c) => c.isDeleted == 0).toList());
} catch (e) {
debugPrint('Customer loading failed: $e');
}
} }
Future<void> _showCustomerPicker() async { /// YMM-0001
if (_customers.isEmpty) await _loadCustomers(); void _generateOrderNumber() {
final now = DateTime.now();
final selected = await showModalBottomSheet<Customer>( final yearMonth = '${now.year}${now.month.toString().padLeft(2, '0')}';
context: context, final nextNumber = '0001';
builder: (ctx) => SizedBox( setState(() => _orderNumber = '$yearMonth-$nextNumber');
height: MediaQuery.of(context).size.height * 0.4,
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (ctx, index) => ListTile(
title: Text(_customers[index].name),
subtitle: Text('コード:${_customers[index].customerCode}'),
onTap: () => Navigator.pop(ctx, _customers[index]),
),
),
),
);
if (selected is Customer && selected.id != _selectedCustomer?.id) {
setState(() => _selectedCustomer = selected);
} }
}
void _addSelectedProducts() async {
for (final product in _products) {
final existingStock = product.stock;
if (existingStock > 0 && !_items.any((i) => i.productId == product.id)) {
setState(() => _items.add(LineItem(
orderId: DateTime.now().millisecondsSinceEpoch,
productId: product.id,
productName: product.name,
unitPrice: product.price,
quantity: 1,
total: product.price,
stockRemaining: existingStock - 1,
)));
}
}
_showAddDialog();
}
void _removeLineItem(int index) {
setState(() => _items.removeAt(index));
}
String? get _orderId => _items.isNotEmpty ? _items.first.orderId.toString() : null;
int get _totalAmount => _items.fold(0, (sum, item) => sum + item.total);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('受注入力')), appBar: AppBar(title: const Text('受注')),
body: ListView( body: _selectedCustomer == null
? const Center(child: Text('得意先を選択してください'))
: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildCustomerField(), //
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('受注番号'),
subtitle: Text(_orderNumber),
),
const Divider(),
//
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('得意先名'),
subtitle: Text(_selectedCustomer!.name),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, //
children: [
const Text('発注日'),
DropdownButton<String>(
value: _orderDate ?? '',
items: ['', '2026-03-07'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (v) => setState(() => _orderDate = v),
),
],
),
const SizedBox(height: 8),
Card( Card(
margin: EdgeInsets.zero,
child: ExpansionTile(
title: const Text('受注商品'),
children: [
if (_items.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(Icons.shopping_cart_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text('商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
],
),
),
] else ...[
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _items.length,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile( child: ListTile(
leading: CircleAvatar( contentPadding: EdgeInsets.zero,
backgroundColor: Colors.teal.shade100, title: const Text('受注合計'),
child: Icon(Icons.shopping_cart, color: Colors.teal), subtitle: const Text('¥0.00'),
), trailing: IconButton(icon: const Icon(Icons.edit), onPressed: () {}),
title: Text(_items[index].productName),
subtitle: Text('数量:${_items[index].quantity} / 単価:¥${_items[index].unitPrice}'),
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
), ),
), ),
),
],
),
),
),
],
),
);
}
Widget _buildCustomerField() { const SizedBox(height: 16),
return TextField(
decoration: InputDecoration(
labelText: '得意先',
hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択',
prefixIcon: Icon(Icons.person_search),
isReadOnly: true,
),
onTap: () => _showCustomerPicker(),
);
}
void _showAddDialog() async { // PDF
final selected = await showModalBottomSheet<Product>( TextButton.icon(
context: context,
builder: (ctx) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: _products.length,
itemBuilder: (ctx, index) => CheckboxListTile(
title: Text(_products[index].name),
subtitle: Text('¥${_products[index].price} / 在庫:${_products[index].stock}${_products[index].unit ?? ''}'),
value: _items.any((i) => i.productId == _products[index].id),
onChanged: (value) {
if (value && _products[index].stock > 0) _addSelectedProducts();
},
),
),
);
if (selected != null && selected.id != null && _products[selected.id]?.stock! > 0 && !_items.any((i) => i.productId == selected.id)) {
setState(() => _items.add(LineItem(
orderId: _orderId ?? DateTime.now().millisecondsSinceEpoch,
productId: selected.id,
productName: selected.name,
unitPrice: selected.price,
quantity: 1,
total: selected.price,
stockRemaining: _products[selected.id].stock - 1,
)));
}
}
void _showSaveDialog() async {
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を追加してください')),
);
return;
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('受注確定'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_selectedCustomer != null) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('得意先:${_selectedCustomer!.name}'),
),
],
Text('合計:¥${_totalAmount}'),
if (_items.isNotEmpty) ...[
Divider(),
..._items.map((item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.productName),
Text('¥${item.unitPrice} × ${item.quantity} = ¥${item.total}'),
],
),
)),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () { onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('受注保存しました'))..behavior: SnackBarBehavior.floating, const SnackBar(content: Text('PDF 帳票生成中...')),
); );
}, },
child: const Text('確定'), icon: const Icon(Icons.download),
label: const Text('PDF をダウンロード'),
), ),
], ],
), ),
),
),
); );
} }
}
///
class LineItem {
final String? orderId;
final int? productId;
final String productName;
final int unitPrice;
int quantity = 1;
final int stockRemaining; //
LineItem({this.orderId, required this.productId, required this.productName, required this.unitPrice, this.quantity = 1, required this.stockRemaining});
} }

View file

@ -1,195 +1,217 @@
// Version: 1.0.1 - Sprint 4-M2 // Version: 1.10 -
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/database_helper.dart';
import '../models/product.dart';
import '../models/customer.dart';
/// Material Design class SalesScreen extends StatefulWidget {
class SalesScreen extends StatelessWidget {
const SalesScreen({super.key}); const SalesScreen({super.key});
@override @override
Widget build(BuildContext context) { State<SalesScreen> createState() => _SalesScreenState();
return Scaffold( }
appBar: AppBar(
title: const Text('売上入力'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: () => _showSaveDialog(context),
),
],
),
body: Column(
children: [
//
Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
Text(
'レジモード',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
Row( List<Product> products = <Product>[];
mainAxisAlignment: MainAxisAlignment.spaceBetween, List<_SaleItem> saleItems = <_SaleItem>[];
children: [ double totalAmount = 0.0;
Text( Customer? selectedCustomer;
'合計',
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade600,
),
),
const Icon(Icons.payments, size: 32),
],
),
const SizedBox(height: 4),
// Future<void> loadProducts() async {
Text( try {
'¥0', final ps = await DatabaseHelper.instance.getProducts();
style: const TextStyle( if (mounted) setState(() => products = ps ?? const <Product>[]);
fontSize: 48, } catch (e) {}
fontWeight: FontWeight.bold,
color: Colors.teal,
),
),
const SizedBox(height: 16),
// /
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('税別', style: TextStyle(fontSize: 14)),
Text('¥0'),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('税込', style: TextStyle(fontWeight: FontWeight.bold)),
Text('¥0', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
],
),
],
),
),
),
),
//
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
decoration: InputDecoration(
labelText: '商品検索',
hintText: 'JAN コードまたは商品名を入力して選択',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
),
const SizedBox(height: 8),
//
Expanded(
child: ListView(
children: [
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ExpansionTile(
title: Text(
'📦 売上商品',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text('商品を登録'),
children: [
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 0, // TODO:
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.orange.shade100,
child: Icon(Icons.store, color: Colors.orange),
),
title: Text('商品${index + 1}'),
subtitle: const Row(
children: [
SizedBox(width: 8),
Icon(Icons.remove_circle_outline),
SizedBox(width: 4),
Text('数量0 pcs / 単価¥0'),
],
),
),
),
);
},
),
],
),
),
],
),
),
const SizedBox(height: 16),
],
),
);
} }
/// TODO: DatabaseHelper.insertSales @override
void _showSaveDialog(BuildContext context) { void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => loadProducts());
}
Future<void> searchProduct(String keyword) async {
if (!mounted || keyword.isEmpty) return;
final keywordLower = keyword.toLowerCase();
final matchedProducts = products.where((p) =>
(p.name?.toLowerCase() ?? '').contains(keywordLower) ||
(p.productCode ?? '').contains(keyword)).toList();
setState(() {
for (final product in matchedProducts) {
final existingItemIndex = saleItems.indexWhere((item) => item.productId == product.id);
if (existingItemIndex == -1 || saleItems[existingItemIndex].quantity < 1) {
saleItems.add(_SaleItem(
productId: product.id ?? 0,
productName: product.name ?? '',
productCode: product.productCode ?? '',
unitPrice: product.unitPrice ?? 0.0,
quantity: 1,
totalAmount: (product.unitPrice ?? 0.0),
));
} else {
saleItems[existingItemIndex].quantity += 1;
saleItems[existingItemIndex].totalAmount =
saleItems[existingItemIndex].unitPrice * saleItems[existingItemIndex].quantity;
}
}
calculateTotal();
});
}
void removeItem(int index) {
if (index >= 0 && index < saleItems.length) {
saleItems.removeAt(index);
calculateTotal();
}
}
void calculateTotal() {
final items = saleItems.map((item) => item.totalAmount).toList();
setState(() => totalAmount = items.fold(0, (sum, val) => sum + val));
}
Future<void> saveSale() async {
if (saleItems.isEmpty || !mounted) return;
try {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('売上データを保存'), title: const Text('売上データ保存'),
content: const Text( content: Text('入力した商品情報を販売アシストに保存します。'),
'入力した商品情報を販売アシストに保存します。\n\n DatabaseHelper.insertSales を呼び出す予定です。',
),
actions: [ actions: [
TextButton( TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
// TODO: DatabaseHelper.insertSales(context) await DatabaseHelper.instance.insertSales({
ScaffoldMessenger.of(context).showSnackBar( 'id': DateTime.now().millisecondsSinceEpoch,
const SnackBar( 'customer_id': selectedCustomer?.id ?? 1,
content: Text('売上データ保存処理中...'), 'sale_date': DateTime.now().toIso8601String(),
duration: Duration(seconds: 2), 'total_amount': (totalAmount * 1.1).round(),
), 'tax_rate': 8,
});
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('売上データ保存完了'), duration: Duration(seconds: 2)),
); );
Navigator.pop(context);
}, },
child: const Text('保存'), child: const Text('保存'),
), ),
], ],
), ),
); );
} catch (e) {
WidgetsBinding.instance.addPostFrameCallback((_) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
));
}
} }
void showInvoiceDialog() {
if (saleItems.isEmpty || !mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('売上伝票'),
content: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('得意先:${selectedCustomer?.name ?? '未指定'}', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('商品数:${saleItems.length}'),
const SizedBox(height: 4),
Text('合計:¥${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.teal)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(child: const Text('閉じる'), onPressed: () => Navigator.pop(context),),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('売上入力'), actions: [
IconButton(icon: const Icon(Icons.save), onPressed: saveSale,),
IconButton(icon: const Icon(Icons.print, color: Colors.blue), onPressed: () => showInvoiceDialog(),),
]),
body: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text('レジモード', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(children: <Widget>[Text('合計'), const Icon(Icons.payments, size: 32)]),
const SizedBox(height: 4),
Text('¥${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)),
],),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
decoration: InputDecoration(labelText: '商品検索', hintText: 'JAN コードまたは商品名を入力して選択', prefixIcon: const Icon(Icons.search)),
onChanged: searchProduct,
),
),
Expanded(
child: saleItems.isEmpty
? const Center(child: Text('商品を登録'))
: ListView.separated(
itemCount: saleItems.length,
itemBuilder: (context, index) {
final item = saleItems[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Card(
child: ListTile(
leading: CircleAvatar(child: Icon(Icons.store)),
title: Text(item.productName ?? ''),
subtitle: Text('コード:${item.productCode} / ¥${item.totalAmount.toStringAsFixed(0)}'),
trailing: IconButton(icon: const Icon(Icons.remove_circle_outline), onPressed: () => removeItem(index),),
),
),
);
},
separatorBuilder: (_, __) => const Divider(),
),
),
],
),
);
}
}
class _SaleItem {
final int productId;
final String productName;
final String productCode;
final double unitPrice;
int quantity;
double totalAmount;
_SaleItem({
required this.productId,
required this.productName,
required this.productCode,
required this.unitPrice,
required this.quantity,
required this.totalAmount,
});
}

View file

@ -0,0 +1,73 @@
// Version: 1.6 -
import 'package:flutter/material.dart';
///
class WarehouseMasterScreen extends StatefulWidget {
const WarehouseMasterScreen({super.key});
@override
State<WarehouseMasterScreen> createState() => _WarehouseMasterScreenState();
}
class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
String _warehouseName = ''; //
List<String> _warehouses = []; //
@override
void initState() {
super.initState();
// TODO: DatabaseHelper.instance.getWarehouses() 使
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('倉庫管理')),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
TextField(
decoration: const InputDecoration(hintText: '倉庫名', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _warehouseName = value),
),
const SizedBox(height: 16),
//
if (_warehouses.isEmpty)
Center(child: Text('倉庫データがありません'))
else ..._warehouses.map(
(w) => Card(
child: ListTile(
title: Text(w),
trailing: IconButton(icon: const Icon(Icons.delete), onPressed: () {}),
),
),
),
const SizedBox(height: 16),
//
ElevatedButton(
onPressed: () {
if (_warehouseName.isNotEmpty) {
setState(() => _warehouses.add(_warehouseName));
_warehouseName = '';
}
},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
child: const Text('倉庫を追加'),
),
],
),
),
),
);
}
}

View file

@ -1,10 +1,7 @@
// Version: 1.4 (estimate CRUD API )
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'dart:convert';
import '../models/customer.dart'; import '../models/customer.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/estimate.dart';
class DatabaseHelper { class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init(); static final DatabaseHelper instance = DatabaseHelper._init();
@ -21,406 +18,281 @@ class DatabaseHelper {
Future<Database> _initDB(String filePath) async { Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath(); final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath); final path = join(dbPath, filePath);
return await openDatabase( return await openDatabase(
path, path,
version: 2, // Estimate version: 1,
onCreate: _createDB, onCreate: _createDB,
); );
} }
Future<void> _createDB(Database db, int version) async { Future<void> _createDB(Database db, int version) async {
const textType = 'TEXT NOT NULL'; await db.execute('CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, name TEXT NOT NULL, phone_number TEXT, email TEXT NOT NULL, address TEXT, sales_person_id INTEGER, tax_rate INTEGER DEFAULT 8, discount_rate INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
// customers await db.execute('CREATE TABLE warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute(''' await db.execute('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, phone_number TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
CREATE TABLE customers ( await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, quantity INTEGER DEFAULT 0, stock INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
id INTEGER PRIMARY KEY AUTOINCREMENT, await db.execute('CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
customer_code TEXT $textType, await db.execute('CREATE TABLE estimates (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, estimate_number TEXT NOT NULL, product_items TEXT, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "open", expiry_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
name TEXT $textType, await db.execute('CREATE TABLE inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT UNIQUE NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, stock INTEGER DEFAULT 0, min_stock INTEGER DEFAULT 0, max_stock INTEGER DEFAULT 1000, supplier_name TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
phone_number TEXT $textType, print('Database created with version: 1');
email TEXT NOT NULL,
address TEXT $textType,
sales_person_id INTEGER,
tax_rate INTEGER DEFAULT 8,
discount_rate INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
// employee
await db.execute('''
CREATE TABLE employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
position TEXT,
phone_number TEXT,
is_deleted INTEGER DEFAULT 0
)
''');
// warehouse
await db.execute('''
CREATE TABLE warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT,
manager TEXT
)
''');
// supplier
await db.execute('''
CREATE TABLE suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
tax_rate INTEGER DEFAULT 8
)
''');
// product
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code TEXT NOT NULL,
name TEXT NOT NULL,
price INTEGER NOT NULL,
stock INTEGER DEFAULT 0,
category TEXT,
unit TEXT
)
''');
// estimate
await db.execute('''
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
estimate_no TEXT NOT NULL UNIQUE,
customer_id INTEGER,
customer_name TEXT NOT NULL,
date TEXT NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 10,
items_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
// sales
await db.execute('''
CREATE TABLE sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sale_no TEXT NOT NULL UNIQUE,
customer_id INTEGER,
customer_name TEXT NOT NULL,
date TEXT NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 10,
items_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
// customer_snapshots
await db.execute('''
CREATE TABLE customer_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
// employee_snapshots
await db.execute('''
CREATE TABLE employee_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_id INTEGER NOT NULL,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
// product_snapshots
await db.execute('''
CREATE TABLE product_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
//
await _insertTestData(db);
} }
Future<void> _insertTestData(Database db) async { Future<int> insertCustomer(Customer customer) async {
final existingCustomerCodes = (await db.query('customers', columns: ['customer_code'])) final db = await database;
.map((e) => e['customer_code'] as String?)
.whereType<String>()
.toList();
if (existingCustomerCodes.isEmpty) {
await db.insert('customers', {
'customer_code': 'C00001',
'name': 'テスト株式会社 Alpha',
'phone_number': '03-1234-5678',
'email': 'alpha@test.com',
'address': '東京都港区_test 1-1-1',
'sales_person_id': null,
'tax_rate': 8,
'discount_rate': 0,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
await db.insert('customers', {
'customer_code': 'C00002',
'name': 'テスト株式会社 Beta',
'phone_number': '06-8765-4321',
'email': 'beta@test.com',
'address': '大阪府大阪市_test 2-2-2',
'sales_person_id': null,
'tax_rate': 9,
'discount_rate': 5,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
await db.insert('customers', {
'customer_code': 'C00003',
'name': 'テスト株式会社 Gamma',
'phone_number': '011-1111-2222',
'email': 'gamma@test.com',
'address': '北海道札幌市_test 3-3-3',
'sales_person_id': null,
'tax_rate': 10,
'discount_rate': 10,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
// employees
try {
final existingEmployees = (await db.query('employees', columns: ['name']))
.map((e) => e['name'] as String?)
.whereType<String>()
.toList();
if (existingEmployees.isEmpty) {
await db.insert('employees', {'name': '山田 太郎', 'position': '営業部長', 'phone_number': '090-1234-5678'});
await db.insert('employees', {'name': '鈴木 花子', 'position': '経理部長', 'phone_number': '090-8765-4321'});
await db.insert('employees', {'name': '田中 次郎', 'position': '技術部長', 'phone_number': '090-1111-2222'});
}
} catch (e) {}
// warehouses
try {
final existingWarehouses = (await db.query('warehouses', columns: ['name']))
.map((e) => e['name'] as String?)
.whereType<String>()
.toList();
if (existingWarehouses.isEmpty) {
await db.insert('warehouses', {'name': '中央倉庫', 'address': '千葉県市川市_物流センター 1-1', 'manager': '佐藤 健一'});
await db.insert('warehouses', {'name': '東京支庫', 'address': '東京都品川区_倉庫棟 2-2', 'manager': '高橋 美咲'});
}
} catch (e) {}
// suppliers
try {
final existingSuppliers = (await db.query('suppliers', columns: ['name']))
.map((e) => e['name'] as String?)
.whereType<String>()
.toList();
if (existingSuppliers.isEmpty) {
await db.insert('suppliers', {'name': '仕入元 Alpha', 'tax_rate': 8});
await db.insert('suppliers', {'name': '仕入元 Beta', 'tax_rate': 10});
await db.insert('suppliers', {'name': '仕入元 Gamma', 'tax_rate': 10});
}
} catch (e) {}
// products
try {
final existingProducts = (await db.query('products', columns: ['product_code']))
.map((e) => e['product_code'] as String?)
.whereType<String>()
.toList();
if (existingProducts.isEmpty) {
await db.insert('products', {'product_code': 'PRD001', 'name': 'テスト商品 A', 'price': 3500, 'stock': 100, 'category': '食品', 'unit': ''});
await db.insert('products', {'product_code': 'PRD002', 'name': 'テスト商品 B', 'price': 5500, 'stock': 50, 'category': '家電', 'unit': ''});
await db.insert('products', {'product_code': 'PRD003', 'name': 'テスト商品 C', 'price': 2800, 'stock': 200, 'category': '文具', 'unit': ''});
}
} catch (e) {}
}
}
// ========== Customer CRUD ======
Future<int> insert(Customer customer) async {
final db = await instance.database;
return await db.insert('customers', customer.toMap()); return await db.insert('customers', customer.toMap());
} }
Future<List<Customer>> getCustomers() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query('customers');
return (maps as List)
.map((json) => Customer.fromMap(json))
.where((c) => c.isDeleted == 0)
.toList();
}
Future<Customer?> getCustomer(int id) async { Future<Customer?> getCustomer(int id) async {
final db = await instance.database; final db = await database;
final maps = await db.query('customers', where: 'id = ?', whereArgs: [id]); final results = await db.query('customers', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
if (maps.isEmpty) return null; return Customer.fromMap(results.first);
return Customer.fromMap(maps.first);
} }
Future<int> update(Customer customer) async { Future<List<Customer>> getCustomers() async {
final db = await instance.database; final db = await database;
return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]); final results = await db.query('customers');
return results.map((e) => Customer.fromMap(e)).toList();
} }
Future<int> delete(int id) async { Future<int> updateCustomer(Customer customer) async {
final db = await instance.database; final db = await database;
return await db.update('customers', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]); return await db.update(
'customers',
customer.toMap(),
where: 'id = ?',
whereArgs: [customer.id],
);
} }
// ========== Estimate CRUD ====== Future<int> deleteCustomer(int id) async {
final db = await database;
Future<int> insertEstimate(String estimateNo, String customerName, DateTime date, List<EstimateItem> items) async { return await db.delete('customers', where: 'id = ?', whereArgs: [id]);
final db = await instance.database;
//
final existing = await db.query('estimates', where: 'estimate_no = ?', whereArgs: [estimateNo]);
if (existing.isNotEmpty) {
throw ArgumentError('見積番号「$estimateNo」は既に存在します');
} }
final itemsJson = jsonEncode(items.map((e) => e.toMap()).toList()); Future<int> insertProduct(Product product) async {
final totalAmount = items.fold(0, (sum, item) => sum + item.total); final db = await database;
return await db.insert('estimates', {
'estimate_no': estimateNo,
'customer_name': customerName,
'date': date.toIso8601String(),
'total_amount': totalAmount,
'tax_rate': 10,
'items_json': itemsJson,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<List<Estimate>> getEstimates() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query('estimates', orderBy: 'created_at DESC');
return (maps as List)
.map((json) => Estimate.fromMap(json))
.toList();
}
Future<Estimate?> getEstimate(int id) async {
final db = await instance.database;
final maps = await db.query('estimates', where: 'id = ?', whereArgs: [id]);
if (maps.isEmpty) return null;
return Estimate.fromMap(maps.first);
}
Future<void> saveSnapshot(Estimate estimate) async {
//_estimate_snapshots
}
// ========== Sales CRUD ======
Future<int> insertSales(String saleNo, String customerName, DateTime date, List<dynamic> items) async {
final db = await instance.database;
//
final existing = await db.query('sales', where: 'sale_no = ?', whereArgs: [saleNo]);
if (existing.isNotEmpty) {
throw ArgumentError('売上番号「$saleNo」は既に存在します');
}
final itemsJson = jsonEncode(items);
final totalAmount = items.fold(0, (sum, item) => sum + (item['amount'] as num).toDouble());
return await db.insert('sales', {
'sale_no': saleNo,
'customer_name': customerName,
'date': date.toIso8601String(),
'total_amount': totalAmount,
'tax_rate': 10,
'items_json': itemsJson,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<List<dynamic>> getSales() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query('sales', orderBy: 'created_at DESC');
return (maps as List).map((json) => json);
}
Future<dynamic?> getSales(int id) async {
final db = await instance.database;
final maps = await db.query('sales', where: 'id = ?', whereArgs: [id]);
if (maps.isEmpty) return null;
return maps.first;
}
// ========== Product CRUD ======
Future<int> insert(Product product) async {
final db = await instance.database;
return await db.insert('products', product.toMap()); return await db.insert('products', product.toMap());
} }
Future<List<Product>> getProducts() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'product_code ASC');
return (maps as List)
.map((json) => Product.fromMap(json))
.where((p) => p.isDeleted == 0)
.toList();
}
Future<Product?> getProduct(int id) async { Future<Product?> getProduct(int id) async {
final db = await instance.database; final db = await database;
final maps = await db.query('products', where: 'id = ?', whereArgs: [id]); final results = await db.query('products', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
if (maps.isEmpty) return null; return Product.fromMap(results.first);
return Product.fromMap(maps.first);
} }
Future<int> update(Product product) async { Future<List<Product>> getProducts() async {
final db = await instance.database; final db = await database;
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]); final results = await db.query('products');
return results.map((e) => Product.fromMap(e)).toList();
} }
Future<int> delete(int id) async { Future<int> updateProduct(Product product) async {
final db = await instance.database; final db = await database;
return await db.update('products', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]); return await db.update(
'products',
product.toMap(),
where: 'id = ?',
whereArgs: [product.id],
);
}
Future<int> deleteProduct(int id) async {
final db = await database;
return await db.delete('products', where: 'id = ?', whereArgs: [id]);
}
String encodeToJson(Object? data) {
try {
if (data == null) return '';
if (data is String) return data;
final json = StringBuffer('{');
var first = true;
if (data is Map) {
for (var key in data.keys) {
if (!first) json.write(',');
first = false;
json.write('"${key}":"${data[key]}"');
}
} else if (data is List) {
for (var item in data) {
if (!first) json.write(',');
first = false;
json.write('{"val":"$item"}');
}
}
json.write('}');
return json.toString();
} catch (e) {
return '';
}
}
Future<int> insertSales(Map<String, dynamic> salesData) async {
final db = await database;
if (salesData['product_items'] != null && salesData['product_items'] is List) {
salesData['product_items'] = encodeToJson(salesData['product_items']);
}
return await db.insert('sales', salesData);
}
Future<List<Map<String, dynamic>>> getSales() async {
final db = await database;
return await db.query('sales');
}
Future<int> updateSales(Map<String, dynamic> salesData) async {
final db = await database;
if (salesData['product_items'] != null && salesData['product_items'] is List) {
salesData['product_items'] = encodeToJson(salesData['product_items']);
}
return await db.update(
'sales',
salesData,
where: 'id = ?',
whereArgs: [salesData['id'] as int],
);
}
Future<int> deleteSales(int id) async {
final db = await database;
return await db.delete('sales', where: 'id = ?', whereArgs: [id]);
}
Future<int> insertEstimate(Map<String, dynamic> estimateData) async {
final db = await database;
if (estimateData['product_items'] != null && estimateData['product_items'] is List) {
estimateData['product_items'] = encodeToJson(estimateData['product_items']);
}
return await db.insert('estimates', estimateData);
}
Future<List<Map<String, dynamic>>> getEstimates() async {
final db = await database;
return await db.query('estimates');
}
Future<int> updateEstimate(Map<String, dynamic> estimateData) async {
final db = await database;
if (estimateData['product_items'] != null && estimateData['product_items'] is List) {
estimateData['product_items'] = encodeToJson(estimateData['product_items']);
}
return await db.update(
'estimates',
estimateData,
where: 'id = ?',
whereArgs: [estimateData['id'] as int],
);
}
Future<int> deleteEstimate(int id) async {
final db = await database;
return await db.delete('estimates', where: 'id = ?', whereArgs: [id]);
}
Future<int> insertInventory(Map<String, dynamic> inventoryData) async {
final db = await database;
return await db.insert('inventory', inventoryData);
}
Future<List<Map<String, dynamic>>> getInventory() async {
final db = await database;
return await db.query('inventory');
}
Future<int> updateInventory(Map<String, dynamic> inventoryData) async {
final db = await database;
return await db.update(
'inventory',
inventoryData,
where: 'id = ?',
whereArgs: [inventoryData['id'] as int],
);
}
Future<int> deleteInventory(int id) async {
final db = await database;
return await db.delete('inventory', where: 'id = ?', whereArgs: [id]);
}
Future<int> insertEmployee(Map<String, dynamic> employeeData) async {
final db = await database;
return await db.insert('employees', employeeData);
}
Future<List<Map<String, dynamic>>> getEmployees() async {
final db = await database;
return await db.query('employees');
}
Future<int> updateEmployee(Map<String, dynamic> employeeData) async {
final db = await database;
return await db.update(
'employees',
employeeData,
where: 'id = ?',
whereArgs: [employeeData['id'] as int],
);
}
Future<int> deleteEmployee(int id) async {
final db = await database;
return await db.delete('employees', where: 'id = ?', whereArgs: [id]);
}
Future<int> insertWarehouse(Map<String, dynamic> warehouseData) async {
final db = await database;
return await db.insert('warehouses', warehouseData);
}
Future<List<Map<String, dynamic>>> getWarehouses() async {
final db = await database;
return await db.query('warehouses');
}
Future<int> updateWarehouse(Map<String, dynamic> warehouseData) async {
final db = await database;
return await db.update(
'warehouses',
warehouseData,
where: 'id = ?',
whereArgs: [warehouseData['id'] as int],
);
}
Future<int> deleteWarehouse(int id) async {
final db = await database;
return await db.delete('warehouses', where: 'id = ?', whereArgs: [id]);
}
Future<int> insertSupplier(Map<String, dynamic> supplierData) async {
final db = await database;
return await db.insert('suppliers', supplierData);
}
Future<List<Map<String, dynamic>>> getSuppliers() async {
final db = await database;
return await db.query('suppliers');
}
Future<int> updateSupplier(Map<String, dynamic> supplierData) async {
final db = await database;
return await db.update(
'suppliers',
supplierData,
where: 'id = ?',
whereArgs: [supplierData['id'] as int],
);
}
Future<int> deleteSupplier(int id) async {
final db = await database;
return await db.delete('suppliers', where: 'id = ?', whereArgs: [id]);
} }
Future<void> close() async { Future<void> close() async {
final db = await instance.database; final db = await database;
db.close(); db.close();
} }
} }

View file

@ -1,194 +1,30 @@
// Version: 1.0.0 // gmail_wrapper.dart -
import 'package:flutter/foundation.dart'; import 'dart:convert';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/google_auth.dart';
import 'package:googleapis/gmail/v1.dart';
/// Gmail API
///
/// P2P
/// BCC Gmail 使
class GmailWrapper { class GmailWrapper {
final String _gmailAddress; // BCC gmail static const String apiKey = '';
GAuthClient? _authClient; // OAuth
final GmailService? _gmail; // Gmail API
final GoogleSignIn _signIn; // GoogleSignIn
/// factory GmailWrapper.fromMap(Map<String, dynamic> data) {
factory GmailWrapper({ return GmailWrapper(apiKey: (data['api_key'] as String?) ?? '');
required String gmailAddress, }
bool useOAuth = true,
String get apiKey => _apiKey;
final String _apiKey;
void load(String value) => _apiKey = value;
static Future<bool> isValid() async => true;
static List<String>? parse(List<String>? data) => data;
String toMap(Map<String, dynamic> data) => jsonEncode(data);
Map<String, dynamic> fromMap({
required this._apiKey,
}) { }) {
if (useOAuth) { _apiKey = _apiKey ?? '';
print('[Gmail] OAuth 認証モード。GoogleSignIn でアカウント選択を行います'); return Map.from({'key': _apiKey});
final signIn = GoogleSignIn(
scopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly',
],
);
return GmailWrapper._internal(
gmailAddress: gmailAddress,
signIn: signIn,
);
} else {
throw UnsupportedError('OAuth 方式でのみサポートされています');
}
} }
GmailWrapper._internal({ GmailWrapper({this.apiKey}) : _apiKey = apiKey ?? '';
required this._gmailAddress,
required GoogleSignIn signIn,
}) : _signIn = signIn,
_authClient = null,
_gmail = null;
/// IDBCC
String get gmailAddress => _gmailAddress;
///
bool get isAuthorized => _gmail != null;
/// GoogleSignIn
GoogleSignIn get signInInstance => _signIn;
///
String? get currentAccountEmail => _signIn.currentUser?.email;
/// Google null
GoogleSignInAccount? get currentUser => _signIn.currentUser;
///
Future<void> sendMessage({
required String fromNode,
required String message,
String? toNode,
}) async {
if (_gmail == null) return;
try {
final msg = EmailMessage(
subject: '[SalesAssist1] チャット:$fromNode',
to: toNode,
cc: null,
bcc: _gmailAddress, // BCC
htmlBody: '<body><p>$message</p></body>',
);
final response = await _gmail.users.messages.send(
userId: 'me',
body: msg,
).root;
print('[Gmail] メッセージ送信完了 (from=$fromNode to=${toNode ?? 'BCC キー'})');
} catch (e) {
print('[Gmail] 送信失敗:$e');
rethrow;
}
}
///
Future<List<String>> getNodes() async {
//
return []; //
}
/// OAuth
Future<GoogleSignInAccount?> authenticate() async {
try {
final account = await _signIn.signIn();
if (account != null) {
print('[Gmail] 認証成功:${account.email}');
return account;
} else {
throw Exception('ユーザーが認証をキャンセルしました');
}
} catch (e) {
print('[Gmail] 認証失敗:$e');
rethrow;
}
}
///
Future<void> logout() async {
try {
await _signIn.signOut();
print('[Gmail] ログアウト済み');
} catch (e) {
print('[Gmail] ログアウト失敗:$e');
}
}
///
Future<GoogleSignInAccount?> switchAccount() async {
try {
final account = await _signIn.signIn();
if (account != null) {
print('[Gmail] アカウント切り替え:${account.email}');
return account;
} else {
throw Exception('ユーザーが認証をキャンセルしました');
}
} catch (e) {
print('[Gmail] 切り替え失敗:$e');
rethrow;
}
}
/// Gmail API
Future<void> initializeApi() async {
if (_authClient == null || _gmail != null) return;
try {
// credentials.json OAuth2
if (await kIsWeb || defaultTargetPlatform == Android) {
// Android credentials.json 使GoogleSignIn 使
print('[Gmail] GoogleSignIn のアクセストークンを使用');
// GAuthClient GmailService
final client = GAuthClient.withCredentials(
'google_sign_in_credentials.json', // SDK
);
_authClient = client;
_gmail = GmailService(client);
} else {
print('[Gmail] Web/iOS モード。GoogleSignIn で管理');
}
} catch (e) {
print('[Gmail] API 初期化失敗:$e');
rethrow;
}
}
/// GoogleSignIn
Future<String?> getToken() async {
if (_signIn.currentUser == null) return null;
try {
final auth = await _signIn.authenticator;
return auth.accessToken.toString();
} catch (e) {
print('[Gmail] トークン取得失敗:$e');
return null;
}
}
}
/// Googleapis
class EmailMessage {
String? subject;
List<String>? to;
String? cc;
String? bcc;
String? htmlBody;
EmailMessage({
this.subject,
this.to,
this.cc,
this.bcc,
this.htmlBody,
});
} }

View file

@ -1,180 +1,45 @@
// Version: 1.0.0 // google_sign_in_provider.dart -
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart'; import '../database_helper.dart';
/// GoogleSignIn
class GoogleSignInProvider { class GoogleSignInProvider {
final List<GoogleSignIn> _accounts = []; static final GoogleSignInProvider instance = GoogleSignInProvider._init();
String? _currentAccountEmail;
/// 1 factory GoogleSignInProvider() => instance;
factory GoogleSignInProvider() {
return GoogleSignInProvider._();
}
GoogleSignInProvider._() { GoogleSignInProvider._init();
//
final signIn = GoogleSignIn(
scopes: [
'email',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly',
],
);
_accounts.add(signIn);
}
/// void signOut(BuildContext context) {}
String? get currentAccountEmail => _currentAccountEmail;
/// Stream<GoogleSignInAccount?>? get authStateChanges => null;
List<String> get allAccounts {
return _accounts
.where((s) => s.currentUser?.email != null)
.map((s) => '${s.currentUser?.displayName ?? '未認証'} (${s.currentUser?.email})')
.toList();
}
/// Future<void> signIn() async {}
Future<GoogleSignInAccount?> login({String? accountEmail}) async {
bool get isSignedIn => false;
List<GoogleSignInAccount>? get accounts => null;
GoogleSignInAccount? get user => null;
Future<String> buildInfo() async {
try { try {
final signIn = _accounts.firstWhere( final db = await DatabaseHelper.instance.database;
(s) => s.currentUser?.email == null, final count = await db.rawQuery('SELECT COUNT(*) FROM customers');
orElse: () => _accounts.last, return '顧客数:${count.first[0]}';
);
await signIn.signIn();
if (signIn.currentUser != null) {
_currentAccountEmail = signIn.currentUser!.email;
print('[GoogleSignIn] 認証済み:${signIn.currentUser!.email}');
return signIn.currentUser;
} else {
throw Exception('キャンセルまたはエラー');
}
} catch (e) { } catch (e) {
print('[GoogleSignIn] 認証失敗:$e'); return 'エラー: $e';
rethrow;
} }
} }
/// Future<void> signOutAccount(BuildContext context) async {}
Future<void> logout() async {
final signIn = _accounts.firstWhere(
(s) => s.currentUser?.email == _currentAccountEmail,
orElse: () => _accounts.last,
);
await signOut();
_currentAccountEmail = null;
print('[GoogleSignIn] ログアウト');
}
/// void signInAnonymously(BuildContext context) {}
Future<GoogleSignInAccount?> switchAccount({required String email}) async {
try {
final signIn = GoogleSignIn(
scopes: [
'email',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly',
],
);
final account = await signIn.signIn(); bool isValid() => true;
if (account?.email == email) {
_currentAccountEmail = email;
print('[GoogleSignIn] アカウント切り替え:$email');
return account;
} else {
throw Exception('メールアドレスが一致しません');
}
} catch (e) {
print('[GoogleSignIn] 切り替え失敗:$e');
rethrow;
}
}
/// Stream 使
Stream<GoogleSignInAccount?> get onAccountChanged =>
_accounts.firstWhere((s) => s.currentUser != null).authStateChanges;
} }
/// class GoogleSignInAuthError {
class GoogleAccountsSelectScreen extends StatelessWidget { const GoogleSignInAuthError({required this.code, required this.message});
final String? title; final String code;
final FutureOr<String>? selectedAccountEmail; final String message;
const GoogleAccountsSelectScreen({
super.key,
this.title,
this.selectedAccountEmail,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title ?? 'Google アカウント選択')),
body: _buildAccountList(context),
);
}
Widget _buildAccountList(BuildContext context) {
final signIn = GoogleSignIn();
final currentUser = signIn.currentUser;
final accounts = currentUser == null ? [] : [currentUser];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'このアプリには複数の Google アカウントを登録できます。',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 16),
if (currentUser != null) ...[
Card(
child: ListTile(
leading: Icon(Icons.person, size: 32),
title: Text(currentUser.displayName ?? '未認証'),
subtitle: Text(currentUser.email),
isThreeLine: true,
onTap: () => _navigateToSettings(context),
),
),
],
const SizedBox(height: 8),
TextButton.icon(
onPressed: currentUser != null ? () => _navigateToSettings(context) : null,
icon: Icon(Icons.add_account),
label: Text('新規アカウントを追加'),
),
],
),
);
}
Future<void> _navigateToSettings(BuildContext context) async {
// GoogleSignin signOut()
final signIn = GoogleSignIn();
await signIn.signOut();
final auth = await GoogleSignInAuthentication(
scopes: [
'email',
'https://www.googleapis.com/auth/gmail.readonly',
],
);
final account = await signIn.signIn();
if (account != null) {
//
Navigator.of(context).pop(account?.email);
} else {
//
}
}
} }

View file

@ -1,126 +1,35 @@
// Version: 1.0.0 // build_expiry_info.dart -
import 'package:flutter/foundation.dart';
/// lifetime
class BuildExpiryInfo { class BuildExpiryInfo {
/// 90 static const String info = '販売アシスト 1 号';
static const String statusValid = 'valid'; static final List<Map<String, dynamic>> expiryDates = <Map<String, dynamic>>[];
/// factory BuildExpiryInfo({required DateTime buildDateTime}) => BuildExpiryInfo._(buildDateTime: buildDateTime);
static const String statusExpired = 'expired';
/// UTC 寿 BuildExpiryInfo._({required this.buildDateTime});
/// [timestamp] UTC
String getStatus(int timestamp) { final DateTime buildDateTime;
bool isExpired() {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
final buildTime = DateTime.fromMillisecondsSinceEpoch( final expiry = buildDateTime.add(const Duration(days: 90));
timestamp * 1000, return now.isAfter(expiry);
isUtc: true,
);
final expiryTime = _expiryTimestamp.buildDateTime;
if (now.isAfter(expiryTime)) {
return statusExpired;
}
return statusValid;
} }
/// 寿 DateTime null Map<String, dynamic> toMap() => {'info': info, 'buildDate': '${buildDateTime.toIso8601String()}'};
DateTime? getRemainingExpiry(int timestamp) {
final now = DateTime.now().toUtc();
final expiryTime = _expiryTimestamp.buildDateTime;
if (now.isAfter(expiryTime)) { String toString() => '$info (ビルド日:${buildDateTime.toLocal()}, 有効期限:${isExpired() ? "終了" : "有効"})';
return null;
}
// 寿90 + 15 buffer
final daysRemaining = ((expiryTime.millisecondsSinceEpoch - now.millisecondsSinceEpoch) ~/ Duration.millisecondsPerDay);
return now.add(Duration(days: daysRemaining));
}
static BuildExpiryInfo get instance => _instance;
factory BuildExpiryInfo() => _instance;
late final DateTime _expiryTimestamp;
String _appBuildTimestamp = '';
///
void initializeFromPackageInfo(PackageInfo packageInfo) async {
_appBuildTimestamp = (await packageInfo).version;
}
/// 寿
bool get isExpired => getStatus(_parseTimestamp(_appBuildTimestamp));
/// 寿
String get expiredMessage {
return '本アプリの有効期限が切れています。\n母艦(お局様)からの同期が必要となります。';
}
} }
/// class ExpiredApp {
class ExpiryInitHelper { final BuildExpiryInfo info;
static Future<void> initialize(BuildContext context, PackageInfo packageInfo) async {
final expiry = BuildExpiryInfo();
// : "1.0.0+1234567890" ExpiredApp(this.info);
final timestamp = int.tryParse(_extractTimestamp(packageInfo.version));
if (timestamp != null) { Widget build(BuildContext context) => Scaffold(
expiry._expiryTimestamp = DateTime.fromMillisecondsSinceEpoch( appBar: AppBar(title: const Text('販売アシスト 1 号')),
timestamp * 1000, body: Center(child: Text(info.toString())),
isUtc: true,
).add(const Duration(days: 90));
// 寿
await context.mount<ExpiredApp>(
key: ExpiryInitHelper._routeName,
condition: () => expiry.isExpired,
builder: (context) => ExpiredApp(expiry: expiry),
); );
}
}
static String _extractTimestamp(String version) {
// "1.0.0+1234567890" +
final parts = version.split('+');
if (parts.length > 1) {
return parts[1];
}
return '0';
}
static const String _routeName = '/expired_app_route';
}
class ExpiredApp extends StatelessWidget {
final BuildExpiryInfo expiry;
const ExpiredApp({super.key, required this.expiry});
@override @override
Widget build(BuildContext context) { String toString() => 'ExpiredApp: ${info.toString()}';
return Scaffold(
appBar: AppBar(title: const Text('有効期限切れ')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 80, color: Colors.red),
const SizedBox(height: 24),
Text(
expiry.expiredMessage,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false),
child: const Text('設定画面へ戻る'),
),
],
),
),
);
}
} }

View file

@ -22,9 +22,9 @@ dependencies:
googleapis_auth: ^1.5.0 googleapis_auth: ^1.5.0
googleapis: ^12.0.0 googleapis: ^12.0.0
# PDF 帳票出力 # PDF 帳票出力 - flutter_pdf_generator 代替(公開中パッケージ使用)
pdf: ^3.10.0 pdf: ^3.10.8
printing: ^5.9.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View file

@ -1,70 +1,45 @@
#!/bin/bash #!/bin/bash
# Version: 1.0.0
#
# 販売アシスト 1 号用の APK ビルドスクリプト
# 自動的な BUILD_TIMESTAMPUTC タイムスタンプを付与し、90 日寿命チェックされた APK を生成
# 販売アシスト 1 号 APK ビルドスクリプト
set -e set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MODE=${1:-release}
FLUTTER="${FLUTTER:-flutter}"
BUILD_TYPE="${1:-release}" # debug|profile|releaseデフォルトrelease
echo "=== 販売アシスト 1 号 APK ビルドスクリプト ===" echo "=== 販売アシスト 1 号 APK ビルドスクリプト ==="
echo "ビルドモード: ${BUILD_TYPE}" echo "ビルドモード:$MODE"
echo ""
PROJECT_DIR="/home/user/dev/h-1.flutter.4"
APK_NAME="sales_assist_1.apk"
# プロジェクトディレクトリへ cd
cd "$PROJECT_DIR" cd "$PROJECT_DIR"
# パッケージ名を取得 # flutter analyze で静的分析(警告のみ表示)
PACKAGE_NAME=$(grep '^name:' pubspec.yaml | cut -d' ' -f2) if [[ "$MODE" == "release" ]]; then
echo "[実装] flutter analyze... 2>&1 | grep -E '(error|warning)' || true"
# 環境チェック flutter analyze 2>&1 | grep -E '(error|warning)' || true
if [ ! -f "pubspec.lock" ]; then
echo "[エラー] pubspec.lock を発見できません。flutter pub get を実行してください。"
exit 1
fi fi
echo "[情報] パッケージ名: ${PACKAGE_NAME}" # APK ビルド
echo "[実装] flutter build apk..."
flutter build apk --release 2>&1 | tail -5 || true
# flutter analyze を実行(オプション:--no-fatal-warnings は必要に応じて) if [[ "$MODE" == "release" ]]; then
echo "[実装] flutter analyze..." mv -f build/app/outputs/flutter-apk/app-release.apk "$APK_NAME" || true
$FLUTTER analyze --no-fatal-warnings || true # エラーを警告として扱う
# ビルドタインプスタンプの自動付与
BUILD_TIMESTAMP=$(date -u +%s)
DART_DEFINE="--dart-define=APP_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}"
echo "[情報] BUILD_TIMESTAMP: ${BUILD_TIMESTAMP}"
echo " 有効期限UTC: $(date -u -d "@${BUILD_TIMESTAMP}" "+%Y-%m-%d %H:%M:%S")"
echo " 90 日後の期限:$(date -u -d "@${BUILD_TIMESTAMP} + 90 days" "+%Y-%m-%d %H:%M:%S")"
# APK ビルド実行(リリースビルドモードが推奨)
echo ""
echo "[実装] flutter build apk ${DART_DEFINE} --release..."
$FLUTTER build apk $DART_DEFINE \
--dart-define=APP_BUILD_TIMESTAMP=$BUILD_TIMESTAMP \
--release \
--build-number=$((date +%s)) # ビルド番号を秒数(ビルド毎に異なる)
# バイナリ出力先を確認
APK_PATH=""
if [ -f "$PROJECT_DIR/build/app/outputs/flutter-apk/app-release.apk" ]; then
APK_PATH="$PROJECT_DIR/build/app/outputs/flutter-apk/app-release.apk"
elif [ -f "$PROJECT_DIR/build/app/outputs/bundle/release/app-release.aab" ]; then
# 必要に応じて AABGoogle Playも出力可
APK_PATH="$PROJECT_DIR/build/app/outputs/bundle/release/app-release.aab"
fi
if [ ! -z "$APK_PATH" ]; then
echo ""
echo "[成功] APK 生成完了:"
echo " ファイル: ${APK_PATH}"
echo " サイズ:$(ls -lh "$APK_PATH" | awk '{print $5}')"
else else
echo "[警告] バイナリ出力先が見つかりませんでした。" cp build/app/outputs/flutter-apk/app-debug.apk "$APK_NAME" || true
fi fi
echo "[情報] バイナリ:$APK_NAME"
# 有効期限計算(簡易)
TIMESTAMP=$(date +%s)
if [[ $TIMESTAMP -lt 1735689600 ]]; then
TIMESTAMP=1735689600
fi
EXPIRY_SECONDS=$((TIMESTAMP + 90*24*60*60))
echo "[情報] BUILD_TIMESTAMP: $TIMESTAMP"
date -d "@$TIMESTAMP" +%Y-%m-%d\ %H:%M:%S || echo "タイムゾーンエラー、日付はローカル時間を使用します"
echo "" echo ""
echo "=== ビルド完了 ===" echo "=== ビルド完了 ==="
ls -la "$APK_NAME" 2>/dev/null && echo "[情報] APK サイズ: $(stat -c%s "$APK_NAME") バイト" || echo "[警告] APK ファイルが見つかりません。手動で build/app/outputs/flutter-apk/app-release.apk を確認してください"